@cratis/pi 2.23.1 → 2.24.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/package/corpus/rules/general.md +2 -0
- package/package/corpus/rules/profiles.md +18 -1
- package/package/corpus/skills/cratis-chronicle-event-modeling/SKILL.md +8 -0
- package/package/corpus/skills/cratis-event-model-diagram/SKILL.md +7 -0
- package/package/corpus/skills/cratis-screenplay-captures-and-reactions/LICENSE +2 -0
- package/package/corpus/skills/cratis-screenplay-captures-and-reactions/SKILL.md +229 -0
- package/package/corpus/skills/cratis-screenplay-captures-and-reactions/verification.json +10 -0
- package/package/corpus/skills/cratis-screenplay-command-surface/LICENSE +2 -0
- package/package/corpus/skills/cratis-screenplay-command-surface/SKILL.md +270 -0
- package/package/corpus/skills/cratis-screenplay-command-surface/references/context.md +111 -0
- package/package/corpus/skills/cratis-screenplay-command-surface/verification.json +23 -0
- package/package/corpus/skills/cratis-screenplay-event-modeling/LICENSE +2 -0
- package/package/corpus/skills/cratis-screenplay-event-modeling/SKILL.md +204 -0
- package/package/corpus/skills/cratis-screenplay-event-modeling/references/nine-steps.md +219 -0
- package/package/corpus/skills/cratis-screenplay-event-modeling/verification.json +23 -0
- package/package/corpus/skills/cratis-screenplay-model-authoring/SKILL.md +13 -1
- package/package/corpus/skills/cratis-screenplay-projections/LICENSE +2 -0
- package/package/corpus/skills/cratis-screenplay-projections/SKILL.md +215 -0
- package/package/corpus/skills/cratis-screenplay-projections/references/pdl-grammar.md +239 -0
- package/package/corpus/skills/cratis-screenplay-projections/verification.json +11 -0
- package/package/corpus/skills/cratis-screenplay-read-surface/LICENSE +2 -0
- package/package/corpus/skills/cratis-screenplay-read-surface/SKILL.md +188 -0
- package/package/corpus/skills/cratis-screenplay-read-surface/verification.json +10 -0
- package/package/corpus/skills/cratis-screenplay-specifications/LICENSE +2 -0
- package/package/corpus/skills/cratis-screenplay-specifications/SKILL.md +199 -0
- package/package/corpus/skills/cratis-screenplay-specifications/verification.json +19 -0
- package/package/corpus/skills/cratis-screenplay-ui-composition/LICENSE +2 -0
- package/package/corpus/skills/cratis-screenplay-ui-composition/SKILL.md +231 -0
- package/package/corpus/skills/cratis-screenplay-ui-composition/verification.json +10 -0
- package/package/corpus/skills/cratis-stage-rendering-and-sandbox/SKILL.md +5 -2
- package/package/profile-catalog.json +8 -1
- package/package.json +1 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-screenplay-projections
|
|
3
|
+
description: Build a Cratis Screenplay read model with the Projection Declaration Language (PDL) — keys, `from`/`every`/`all`, property mapping and AutoMap, joins, children and nested objects, removal, arithmetic counters, and the reducer escape hatch. Use when declaring or changing how events become read-model state in a `.play` model, or when a projection does not populate what was expected. Use `cratis-chronicle-projection` instead for hand-written C# Chronicle projections.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Projections — the Screenplay PDL
|
|
8
|
+
|
|
9
|
+
A `projection` folds events into a read model. Its body is the **Projection
|
|
10
|
+
Declaration Language**, an embedded sub-grammar with its own parser — not free
|
|
11
|
+
text. This is half of every event model, and the half most easily got wrong.
|
|
12
|
+
|
|
13
|
+
## Verified product sources
|
|
14
|
+
|
|
15
|
+
| Package | Version | Purpose |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `Cratis.Screenplay` | `4.12.1` | PDL parser, validator, diagnostics |
|
|
18
|
+
|
|
19
|
+
Read from the Screenplay repository at tag `v4.12.1` (commit `122eee8`), against
|
|
20
|
+
`Documentation/screenplay/projections/` and
|
|
21
|
+
`Source/DotNET/Screenplay/Parsing/ProjectionParser.cs`. Reverify before claiming
|
|
22
|
+
another version behaves the same.
|
|
23
|
+
|
|
24
|
+
## The shape
|
|
25
|
+
|
|
26
|
+
```screenplay
|
|
27
|
+
readmodel InvoiceDetailsReadModel
|
|
28
|
+
invoiceNumber InvoiceNumber
|
|
29
|
+
status InvoiceStatus
|
|
30
|
+
lastUpdatedAt DateTime
|
|
31
|
+
|
|
32
|
+
projection InvoiceDetails => InvoiceDetailsReadModel
|
|
33
|
+
key invoiceId
|
|
34
|
+
every
|
|
35
|
+
lastUpdatedAt = $eventContext.occurred
|
|
36
|
+
exclude children
|
|
37
|
+
from InvoiceRegistered key invoiceId
|
|
38
|
+
status = "draft"
|
|
39
|
+
from InvoiceSent
|
|
40
|
+
status = "sent"
|
|
41
|
+
remove with InvoiceCancelled
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**The arrow always points the same way.** A read model never declares what builds
|
|
45
|
+
it; the projection or reducer points at it with `=>`. **Exactly one thing may
|
|
46
|
+
build a read model** — two builders is error `PLAY0191`: *a projection or a
|
|
47
|
+
reducer builds it, and only one of them*. A slice may still declare several
|
|
48
|
+
projections, each building a **different** read model.
|
|
49
|
+
|
|
50
|
+
## Projection-level directives
|
|
51
|
+
|
|
52
|
+
`key <expr>`, `no automap`, `sequence <name>`, `file <path>`. A second `key` is
|
|
53
|
+
error `PLAY0059`.
|
|
54
|
+
|
|
55
|
+
## Keys — eight ways, and the default
|
|
56
|
+
|
|
57
|
+
| Form | Example |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| Projection-level | `key invoiceId` |
|
|
60
|
+
| From-level inline | `from InvoiceRegistered key invoiceId` |
|
|
61
|
+
| From-level block | indented `key invoiceId` inside the `from` |
|
|
62
|
+
| Per event, several events | `from A key idA, B key idB, C` |
|
|
63
|
+
| Composite | `key OrderKey` with indented `<property> = <expr>` parts |
|
|
64
|
+
| Literal (constant) | `key literal "site-stats"` — every event updates the **same** instance |
|
|
65
|
+
| Children identity | `children lines identified by lineNumber` |
|
|
66
|
+
| **Default** | **the event source id** — `from X` ≡ `from X key $eventSourceId` |
|
|
67
|
+
|
|
68
|
+
A second key on one `from` is error `PLAY0060`. A template expression in a
|
|
69
|
+
composite key is error `PLAY0073`; an empty composite key is `PLAY0074`.
|
|
70
|
+
|
|
71
|
+
## `from` vs `every` vs `all`
|
|
72
|
+
|
|
73
|
+
| Block | Fires for | Use for |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `from <Event>` | that event type only | the actual mapping work |
|
|
76
|
+
| `every` | **only** the types this projection subscribes to via `from` | common fields across your own events |
|
|
77
|
+
| `all` | **every event type in the system** | system-wide audit logs, global counters |
|
|
78
|
+
|
|
79
|
+
⚠️ `all` and `every` are not synonyms, and confusing them is the classic PDL
|
|
80
|
+
mistake. `every` is scoped to your `from` blocks; `all` is not scoped at all.
|
|
81
|
+
|
|
82
|
+
`every` accepts `exclude children`, so child events do not bump the parent's
|
|
83
|
+
`lastUpdatedAt`. The `every` block **inside** a `children` block does not accept
|
|
84
|
+
it — it is already in a children context.
|
|
85
|
+
|
|
86
|
+
## Property mapping and AutoMap
|
|
87
|
+
|
|
88
|
+
⚠️ **AutoMap is on by default.** Matching property names are copied before your
|
|
89
|
+
explicit mappings run, and explicit mappings win. Turn it off with `no automap` at
|
|
90
|
+
**projection**, `every`/`all`, `join … with`, `children` or `nested` level — it
|
|
91
|
+
**cannot** be toggled inside an individual `from` block.
|
|
92
|
+
|
|
93
|
+
Mapping sources: a property path (`name`, `contactInfo.email`), a literal
|
|
94
|
+
(`true`, `"Pending"`, `42`, `null`), a template (`` `${first} ${last}` ``),
|
|
95
|
+
`$eventSourceId`, `$eventContext.<occurred|sequenceNumber|correlationId|eventSourceId>`,
|
|
96
|
+
and `$causedBy.<subject|name|userName>` (an unknown one is an error naming all
|
|
97
|
+
three).
|
|
98
|
+
|
|
99
|
+
`clear <property>` removes a value and is equivalent to `= null`; prefer `clear`.
|
|
100
|
+
It takes a dotted path (`clear Owner.Note`) and escapes reserved names
|
|
101
|
+
(`clear @with`).
|
|
102
|
+
|
|
103
|
+
## Arithmetic
|
|
104
|
+
|
|
105
|
+
| Line | Effect |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `count <prop>` | +1, expressing *counting occurrences* |
|
|
108
|
+
| `increment <prop>` | +1, expressing *modifying state* |
|
|
109
|
+
| `decrement <prop>` | −1 |
|
|
110
|
+
| `add <prop> by <expr>` | += expression |
|
|
111
|
+
| `subtract <prop> by <expr>` | −= expression |
|
|
112
|
+
|
|
113
|
+
`count` and `increment` behave identically; the difference is intent. The target
|
|
114
|
+
must be numeric.
|
|
115
|
+
|
|
116
|
+
## Joins
|
|
117
|
+
|
|
118
|
+
```screenplay
|
|
119
|
+
join Customer on CustomerId
|
|
120
|
+
with CustomerCreated
|
|
121
|
+
no automap
|
|
122
|
+
CustomerName = name
|
|
123
|
+
with CustomerUpdated
|
|
124
|
+
CustomerName = name
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`join <property> on <key>` (error `PLAY0063` otherwise), then one or more
|
|
128
|
+
`with <EventType>` blocks (error `PLAY0064` otherwise), each able to toggle
|
|
129
|
+
`automap` / `no automap` for itself. Joins work at projection level and inside
|
|
130
|
+
`children`. A join cannot declare its own key or trigger removal — that is
|
|
131
|
+
`remove via join on`.
|
|
132
|
+
|
|
133
|
+
## Children and nested
|
|
134
|
+
|
|
135
|
+
```screenplay
|
|
136
|
+
children lineItems identified by lineNumber
|
|
137
|
+
from InvoiceLineItemAdded key lineNumber
|
|
138
|
+
parent invoiceId
|
|
139
|
+
quantity = quantity
|
|
140
|
+
remove with InvoiceLineItemRemoved key lineNumber
|
|
141
|
+
parent invoiceId
|
|
142
|
+
|
|
143
|
+
nested billingContact
|
|
144
|
+
from BillingContactSet
|
|
145
|
+
email = email
|
|
146
|
+
clear with BillingContactCleared
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- **`children <collection> identified by <expr>`** — a collection with its own
|
|
150
|
+
lifecycle. `parent <expr>` ties a child to its parent instance; it accepts an
|
|
151
|
+
event property, `$eventContext.eventSourceId`, or a nested path. Children nest
|
|
152
|
+
arbitrarily deep and may contain `join`, `remove`, `nested` and `every`.
|
|
153
|
+
- **`nested <property>`** — a single nullable object. It **must** contain at least
|
|
154
|
+
one `from` (error `PLAY0067`), and `clear with <Event>` drops the whole object
|
|
155
|
+
back to null. To clear one property instead, use a `clear <property>` mapping.
|
|
156
|
+
|
|
157
|
+
## Removal
|
|
158
|
+
|
|
159
|
+
```screenplay
|
|
160
|
+
remove with InvoiceCancelled key invoiceId
|
|
161
|
+
remove via join on CustomerAccountClosed
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`remove with <Event> [key <expr>]` removes the instance the event identifies.
|
|
165
|
+
`remove via join on <Event> [key <expr>]` removes instances reached through a
|
|
166
|
+
join. Inside `children`, both take an indented `parent <expr>` — and **only**
|
|
167
|
+
`parent`; anything else is error `PLAY0069`. Several removal conditions may
|
|
168
|
+
coexist.
|
|
169
|
+
|
|
170
|
+
## When PDL is not enough — the reducer
|
|
171
|
+
|
|
172
|
+
```screenplay
|
|
173
|
+
reducer Balance => AccountBalance
|
|
174
|
+
on AmountDeposited
|
|
175
|
+
csharp
|
|
176
|
+
```
|
|
177
|
+
return context.State is null
|
|
178
|
+
? new(context.Event.amount, 1)
|
|
179
|
+
: context.State with { balance = context.State.balance + context.Event.amount };
|
|
180
|
+
```
|
|
181
|
+
on AmountWithdrawn
|
|
182
|
+
file Reducers/Withdrawn.cs
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Reach for a reducer only when the next state depends on the current one — a
|
|
186
|
+
running balance, a state machine. `context.State` is **null for the first event**
|
|
187
|
+
and is the only nullable member; `Event`, `Key`, `Tenant`, `Occurred`,
|
|
188
|
+
`SequenceNumber` and `IsFirst` are always there.
|
|
189
|
+
|
|
190
|
+
**Prefer a projection where one will do.** A reducer is code, and code is the part
|
|
191
|
+
of a document a reader cannot check at a glance. A reducer binds to
|
|
192
|
+
`UnsupportedSemanticSyntax` in ESM v1, so it never reaches the reference runtime.
|
|
193
|
+
|
|
194
|
+
Read [pdl-grammar.md](references/pdl-grammar.md) for the complete EBNF, every
|
|
195
|
+
diagnostic code, and the full worked examples.
|
|
196
|
+
|
|
197
|
+
## Verify
|
|
198
|
+
|
|
199
|
+
- [ ] `screenplay <model> --warnaserror` reports zero errors and zero warnings.
|
|
200
|
+
- [ ] Each read model has **exactly one** builder.
|
|
201
|
+
- [ ] Every read-model property traces back to an event that carries it.
|
|
202
|
+
- [ ] `every` was intended where `every` is written — not `all`.
|
|
203
|
+
- [ ] AutoMap's default-on behavior is intended, or `no automap` is declared at the
|
|
204
|
+
right level.
|
|
205
|
+
- [ ] Every `children` block's `from` declares `parent`.
|
|
206
|
+
- [ ] Every `nested` block contains at least one `from`.
|
|
207
|
+
- [ ] A reducer is present only because a projection genuinely could not express it.
|
|
208
|
+
|
|
209
|
+
## Route near misses
|
|
210
|
+
|
|
211
|
+
- Deciding which read models exist at all: `cratis-screenplay-event-modeling`.
|
|
212
|
+
- Read-model shape, queries and screens: `cratis-screenplay-read-surface`.
|
|
213
|
+
- Specifying projection behavior: `cratis-screenplay-specifications`.
|
|
214
|
+
- Hand-written C# Chronicle projections: `cratis-chronicle-projection`.
|
|
215
|
+
- Hand-written C# Chronicle reducers: `cratis-chronicle-reducer`.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# PDL — complete grammar and diagnostics
|
|
2
|
+
|
|
3
|
+
Verified against `Documentation/screenplay/projections/grammar.md` and
|
|
4
|
+
`Source/DotNET/Screenplay/Parsing/ProjectionParser.cs` at commit `122eee8`.
|
|
5
|
+
|
|
6
|
+
Two places below are deliberately **looser than the published EBNF**, because the
|
|
7
|
+
parser accepts more than that EBNF describes and both forms were confirmed by
|
|
8
|
+
compiling them:
|
|
9
|
+
|
|
10
|
+
- `EveryBlock` accepts a bare `automap` as well as `no automap`. The published
|
|
11
|
+
grammar lists only `no automap`, but `from-every.md` shows `automap` in an
|
|
12
|
+
example and `ParseMappingBlock` handles both.
|
|
13
|
+
- The braces on a composite key are **optional**. The published grammar writes
|
|
14
|
+
them as required; a composite key with and without them both compile clean.
|
|
15
|
+
|
|
16
|
+
## Grammar
|
|
17
|
+
|
|
18
|
+
```ebnf
|
|
19
|
+
Projection = "projection", Ident, [ "=>", TypeRef ], NL,
|
|
20
|
+
[ INDENT, { ProjDirective | Block }, DEDENT ] ;
|
|
21
|
+
|
|
22
|
+
ProjDirective = "no", "automap", NL
|
|
23
|
+
| "sequence", Ident, NL
|
|
24
|
+
| "file", FilePath, NL
|
|
25
|
+
| KeyDecl
|
|
26
|
+
| CompositeKeyDecl ;
|
|
27
|
+
|
|
28
|
+
Block = EveryBlock | FromAllBlock | FromEventBlock | JoinBlock
|
|
29
|
+
| ChildrenBlock | NestedBlock | RemoveWithBlock | RemoveWithJoinBlock ;
|
|
30
|
+
|
|
31
|
+
EveryBlock = "every", NL, INDENT,
|
|
32
|
+
[ "automap" | "no", "automap", NL ], { MappingLine },
|
|
33
|
+
[ "exclude", "children", NL ], DEDENT ;
|
|
34
|
+
|
|
35
|
+
FromAllBlock = "all", NL,
|
|
36
|
+
[ INDENT, [ "automap" | "no", "automap", NL ], { MappingLine }, DEDENT ] ;
|
|
37
|
+
|
|
38
|
+
FromEventBlock = "from", EventSpec, { ",", EventSpec }, NL,
|
|
39
|
+
[ INDENT, [ ParentDecl ], { MappingLine | KeyDecl | CompositeKeyDecl }, DEDENT ] ;
|
|
40
|
+
EventSpec = TypeRef, [ "key", Expr ] ;
|
|
41
|
+
|
|
42
|
+
JoinBlock = "join", Ident, "on", Ident, NL, INDENT, { WithEventBlock }, DEDENT ;
|
|
43
|
+
WithEventBlock = "with", TypeRef, NL,
|
|
44
|
+
[ INDENT, [ "automap" | "no", "automap", NL ], { MappingLine }, DEDENT ] ;
|
|
45
|
+
|
|
46
|
+
ChildrenBlock = "children", Ident, "identified", "by", Expr, NL,
|
|
47
|
+
INDENT, [ "no", "automap", NL ], { ChildBlock }, DEDENT ;
|
|
48
|
+
ChildBlock = ChildEveryBlock | FromEventBlock | JoinBlock | RemoveWithBlock
|
|
49
|
+
| RemoveWithJoinBlock | ChildrenBlock | NestedBlock | ClearWithBlock ;
|
|
50
|
+
ChildEveryBlock = "every", NL, INDENT, [ "no", "automap", NL ], { MappingLine }, DEDENT ;
|
|
51
|
+
|
|
52
|
+
NestedBlock = "nested", Ident, NL,
|
|
53
|
+
INDENT, [ "automap" | "no", "automap", NL ],
|
|
54
|
+
{ ProjDirective | Block | NestedBlock | ClearWithBlock }, DEDENT ;
|
|
55
|
+
ClearWithBlock = "clear", "with", TypeRef, NL ;
|
|
56
|
+
|
|
57
|
+
RemoveWithBlock = "remove", "with", TypeRef, [ "key", Expr ], NL,
|
|
58
|
+
[ INDENT, [ ParentDecl ], DEDENT ] ;
|
|
59
|
+
RemoveWithJoinBlock = "remove", "via", "join", "on", TypeRef, [ "key", Expr ], NL ;
|
|
60
|
+
|
|
61
|
+
KeyDecl = "key", Expr, NL ;
|
|
62
|
+
CompositeKeyDecl= "key", TypeRef, [ "{" ], NL,
|
|
63
|
+
INDENT, KeyPart, { NL, KeyPart }, DEDENT, [ "}", NL ] ;
|
|
64
|
+
KeyPart = Ident, "=", Expr ;
|
|
65
|
+
|
|
66
|
+
MappingLine = Assignment | ClearLine | IncLine | DecLine | CountLine | AddLine | SubLine ;
|
|
67
|
+
Assignment = Ident, "=", Expr, NL ;
|
|
68
|
+
ClearLine = "clear", Path, NL ;
|
|
69
|
+
IncLine = "increment", Ident, NL ;
|
|
70
|
+
DecLine = "decrement", Ident, NL ;
|
|
71
|
+
CountLine = "count", Ident, NL ;
|
|
72
|
+
AddLine = "add", Ident, "by", Expr, NL ;
|
|
73
|
+
SubLine = "subtract", Ident, "by", Expr, NL ;
|
|
74
|
+
|
|
75
|
+
Expr = Template | Literal | DollarExpr | Path ;
|
|
76
|
+
DollarExpr = "$eventSourceId" | "$eventContext", ".", Ident | "$causedBy", ".", Ident ;
|
|
77
|
+
Template = "`", { TemplateChar | "${", Expr, "}" }, "`" ;
|
|
78
|
+
Literal = BoolLiteral | StringLiteral | NumberLiteral | NullLiteral ;
|
|
79
|
+
LiteralKeyword = "literal", " ", Literal ; (* parsed at the Expr level *)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Parser regexes
|
|
83
|
+
|
|
84
|
+
| Construct | Regex |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| Projection header | `^projection\s+(@?[\w.]+)\s*(?:=>\s*([\w.]+))?$` |
|
|
87
|
+
| Event spec | `^(@?[\w.]+)(?:\s+key\s+(.+))?$` |
|
|
88
|
+
| Join | `^join\s+(@?[\w.]+)\s+on\s+(@?[\w.]+)$` |
|
|
89
|
+
| With | `^with\s+(@?[\w.]+)$` |
|
|
90
|
+
| Children | `^children\s+(@?[\w.]+)\s+identified\s+by\s+(.+)$` |
|
|
91
|
+
| Nested | `^nested\s+(@?[\w.]+)$` |
|
|
92
|
+
| Remove with | `^remove\s+with\s+(@?[\w.]+)(?:\s+key\s+(.+))?$` |
|
|
93
|
+
| Remove via join | `^remove\s+via\s+join\s+on\s+(@?[\w.]+)(?:\s+key\s+(.+))?$` |
|
|
94
|
+
| Counters | `^(increment\|decrement\|count\|clear)\s+(@?[$\w.]+)$` |
|
|
95
|
+
| Arithmetic | `^(add\|subtract)\s+(@?[$\w.]+)\s+by\s+(.+)$` |
|
|
96
|
+
| Assignment | `^(@?[$\w.@]+)\s*=(?!=\|>)\s*(.+)$` |
|
|
97
|
+
|
|
98
|
+
## Diagnostics
|
|
99
|
+
|
|
100
|
+
| Code | Meaning |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `PLAY0054` | A projection document's top-level line does not open a `projection` |
|
|
103
|
+
| `PLAY0055` | A projection document declares no projection |
|
|
104
|
+
| `PLAY0056` | A `projection` line is not `projection <Name> [=> <ReadModel>]` |
|
|
105
|
+
| `PLAY0057` | A projection declares no directives, so it builds nothing |
|
|
106
|
+
| `PLAY0058` | A projection body line opens with an unknown word |
|
|
107
|
+
| `PLAY0059` | A projection declares more than one key |
|
|
108
|
+
| `PLAY0060` | A `from` block declares more than one key |
|
|
109
|
+
| `PLAY0061` | A `from` line names no event |
|
|
110
|
+
| `PLAY0062` | An event reference is not a readable name |
|
|
111
|
+
| `PLAY0063` | A `join` line is not `join <property> on <key>` |
|
|
112
|
+
| `PLAY0064` | A join block holds a line that is not `with <EventType>` |
|
|
113
|
+
| `PLAY0065` | A `children` line is malformed |
|
|
114
|
+
| `PLAY0066` | A `nested` line is not `nested <property>` |
|
|
115
|
+
| `PLAY0067` | A nested block reads from no event, so nothing ever fills it |
|
|
116
|
+
| `PLAY0068` | A `remove` line is neither `remove with` nor `remove via join on` |
|
|
117
|
+
| `PLAY0069` | A remove block holds a line other than `parent` |
|
|
118
|
+
| `PLAY0070` | A `clear` line is not `clear with <EventType>` |
|
|
119
|
+
| `PLAY0071` | `clear with` is written where there is nothing to clear |
|
|
120
|
+
| `PLAY0072` | A composite key part is not `<property> = <expression>` |
|
|
121
|
+
| `PLAY0073` | A composite key part is a template expression, which a key cannot be |
|
|
122
|
+
| `PLAY0074` | A composite key declares no parts |
|
|
123
|
+
| `PLAY0075` | A projection mapping line is unreadable |
|
|
124
|
+
| `PLAY0186` | A `readmodel` line is not `readmodel <Name>` |
|
|
125
|
+
| `PLAY0187` | A `reducer` line is not `reducer <Name> => <ReadModel>` |
|
|
126
|
+
| `PLAY0188` | A reducer body line is not `on <EventType>` |
|
|
127
|
+
| `PLAY0189` | A reducer declares no rules |
|
|
128
|
+
| `PLAY0190` | A reducer rule body opens with an unknown word |
|
|
129
|
+
| `PLAY0191` | A read model is built by more than one projection or reducer |
|
|
130
|
+
| `PLAY0192` | A read model is declared more than once |
|
|
131
|
+
|
|
132
|
+
## Worked examples
|
|
133
|
+
|
|
134
|
+
### Composite key with event context
|
|
135
|
+
|
|
136
|
+
```screenplay
|
|
137
|
+
projection LineItems => LineItemReadModel
|
|
138
|
+
from LineItemAdded
|
|
139
|
+
key LineItemKey
|
|
140
|
+
OrderId = orderId
|
|
141
|
+
LineNumber = lineNumber
|
|
142
|
+
SequenceNumber = $eventContext.sequenceNumber
|
|
143
|
+
CreatedBy = $causedBy.subject
|
|
144
|
+
Product = productName
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Global counter with a literal key
|
|
148
|
+
|
|
149
|
+
```screenplay
|
|
150
|
+
projection SiteStats => SiteStatsReadModel
|
|
151
|
+
from UserLoggedIn key literal "site-stats"
|
|
152
|
+
count TotalLogins
|
|
153
|
+
lastLogin = $eventContext.occurred
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Every `UserLoggedIn` updates the same instance, whatever its event source.
|
|
157
|
+
|
|
158
|
+
### System-wide audit with `all` alongside `from`
|
|
159
|
+
|
|
160
|
+
```screenplay
|
|
161
|
+
projection ActivityFeed => ActivityFeedModel
|
|
162
|
+
all
|
|
163
|
+
count totalSystemEvents
|
|
164
|
+
lastActivity = $eventContext.occurred
|
|
165
|
+
from UserRegistered
|
|
166
|
+
recentUsers = name
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`totalSystemEvents` counts **every** event in the system; `recentUsers` only fills
|
|
170
|
+
on `UserRegistered`.
|
|
171
|
+
|
|
172
|
+
### Children with a join and scoped removal
|
|
173
|
+
|
|
174
|
+
```screenplay
|
|
175
|
+
projection Group => GroupReadModel
|
|
176
|
+
from GroupCreated
|
|
177
|
+
Name = name
|
|
178
|
+
children members identified by userId
|
|
179
|
+
from UserAddedToGroup key userId
|
|
180
|
+
parent groupId
|
|
181
|
+
Role = role
|
|
182
|
+
join User on UserId
|
|
183
|
+
with UserCreated
|
|
184
|
+
no automap
|
|
185
|
+
UserName = name
|
|
186
|
+
remove with UserRemovedFromGroup key userId
|
|
187
|
+
parent groupId
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### `every` that ignores child activity
|
|
191
|
+
|
|
192
|
+
```screenplay
|
|
193
|
+
projection Group => GroupReadModel
|
|
194
|
+
every
|
|
195
|
+
LastActivity = $eventContext.occurred
|
|
196
|
+
exclude children
|
|
197
|
+
from GroupCreated
|
|
198
|
+
Name = name
|
|
199
|
+
children members identified by userId
|
|
200
|
+
from UserAdded
|
|
201
|
+
Name = userName
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Group-level events bump `LastActivity`; member events do not.
|
|
205
|
+
|
|
206
|
+
### Nested nullable object
|
|
207
|
+
|
|
208
|
+
```screenplay
|
|
209
|
+
projection Slice => SliceReadModel
|
|
210
|
+
from SliceCreated
|
|
211
|
+
Name = name
|
|
212
|
+
nested command
|
|
213
|
+
from CommandSetForSlice
|
|
214
|
+
Name = commandName
|
|
215
|
+
Schema = schema
|
|
216
|
+
from CommandRenamed
|
|
217
|
+
Name = newName
|
|
218
|
+
clear with CommandClearedForSlice
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`command` is null until `CommandSetForSlice`, updated in place by
|
|
222
|
+
`CommandRenamed`, and set back to null by `CommandClearedForSlice`.
|
|
223
|
+
|
|
224
|
+
### Several projections in one slice
|
|
225
|
+
|
|
226
|
+
```screenplay
|
|
227
|
+
slice StateView CustomerPortalReport
|
|
228
|
+
query GetPortalReport => PortalReportReadModel
|
|
229
|
+
by customerId CustomerId
|
|
230
|
+
projection PortalReport => PortalReportReadModel
|
|
231
|
+
from PortalInvitationSent
|
|
232
|
+
invitedAt = $eventContext.occurred
|
|
233
|
+
projection RevokedPortalToken => RevokedPortalTokenReadModel
|
|
234
|
+
from PortalTokenRevoked
|
|
235
|
+
revokedAt = $eventContext.occurred
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Two read models, one behavior. Splitting them across two slices would say the
|
|
239
|
+
system has two behaviors where it has one.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill": "cratis-screenplay-projections",
|
|
3
|
+
"input": "Build a read model from events with the Projection Declaration Language.",
|
|
4
|
+
"fixtures": [],
|
|
5
|
+
"assertions": [
|
|
6
|
+
{ "kind": "skill-contains", "value": "AutoMap is on by default" },
|
|
7
|
+
{ "kind": "skill-contains", "value": "PLAY0191" },
|
|
8
|
+
{ "kind": "skill-contains", "value": "every event type in the system" },
|
|
9
|
+
{ "kind": "skill-contains", "value": "references/pdl-grammar.md" }
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cratis-screenplay-read-surface
|
|
3
|
+
description: Declare the read side of a Cratis Screenplay `.play` model — the `readmodel` shape, `query` with `by`/`filter`/`scoped to`/`observable`/`performer`, and `screen` at its three levels of detail, plus how a bare name resolves across slices. Use when adding or changing a query, a read-model shape, or a screen in a `.play` model, or when deciding what a caller may narrow a result by. Do not use for how events fill the read model, and do not use for layouts, templates or forms.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# The Screenplay read surface
|
|
8
|
+
|
|
9
|
+
What the system can be asked, and what a user sees. The `readmodel` declares the
|
|
10
|
+
shape, the `query` is the entry point, and the `screen` renders it.
|
|
11
|
+
|
|
12
|
+
## Verified product sources
|
|
13
|
+
|
|
14
|
+
| Package | Version | Purpose |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `Cratis.Screenplay` | `4.12.1` | Parser, validator, diagnostics |
|
|
17
|
+
|
|
18
|
+
Read from the Screenplay repository at tag `v4.12.1` (commit `122eee8`), against
|
|
19
|
+
`Documentation/screenplay/{queries,readmodels,screens}.md` and
|
|
20
|
+
`Source/DotNET/Screenplay/Parsing/`. Reverify before claiming another version
|
|
21
|
+
behaves the same.
|
|
22
|
+
|
|
23
|
+
## `readmodel` — shape only
|
|
24
|
+
|
|
25
|
+
```screenplay
|
|
26
|
+
readmodel AccountBalance
|
|
27
|
+
description "What the account is worth right now"
|
|
28
|
+
balance Decimal
|
|
29
|
+
movements Int
|
|
30
|
+
note String?
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A read model declares what it **is** — nothing about what builds it. Whatever
|
|
34
|
+
builds it points at it with `=>`, and **exactly one thing may**: two builders is
|
|
35
|
+
error `PLAY0191`. See `cratis-screenplay-projections` for the builder side.
|
|
36
|
+
|
|
37
|
+
## `query`
|
|
38
|
+
|
|
39
|
+
```screenplay
|
|
40
|
+
query ListInvoices => InvoiceListReadModel[]
|
|
41
|
+
description "Every invoice the caller may see, narrowed by status and customer"
|
|
42
|
+
filter status InvoiceStatus?
|
|
43
|
+
filter customerId CustomerId?
|
|
44
|
+
filter tenantId TenantId from $context.tenant
|
|
45
|
+
authorize IsAuthenticated
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Return-type forms after `=>`: `ReadModel`, `ReadModel?`, `ReadModel[]`, each
|
|
49
|
+
optionally prefixed `observable`.
|
|
50
|
+
|
|
51
|
+
### `by` vs `filter` — and why it is a security decision
|
|
52
|
+
|
|
53
|
+
| Clause | Meaning |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `by` | the **identifying** parameter — the query returns the instance it identifies |
|
|
56
|
+
| `filter` | an optional parameter narrowing the result set; usually typed `?` |
|
|
57
|
+
| `from <source>` | fills the parameter **from the context** instead of from the caller |
|
|
58
|
+
|
|
59
|
+
⚠️ **Anything the caller must not be able to choose — the tenant, the caller's own
|
|
60
|
+
subject — belongs on a `from` parameter, never on a `filter` the UI supplies.**
|
|
61
|
+
Any mapping source works as a `from` source, so `$context.`, `$env.` and constants
|
|
62
|
+
are all available.
|
|
63
|
+
|
|
64
|
+
### `scoped to`
|
|
65
|
+
|
|
66
|
+
```screenplay
|
|
67
|
+
query Mine => Timesheet[]
|
|
68
|
+
scoped to identity
|
|
69
|
+
query Everyones => Timesheet[]
|
|
70
|
+
scoped to global
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
⚠️ **The tenant is the default and stays unstated.** A query is scoped to the
|
|
74
|
+
tenant it runs for unless it says otherwise, so `scoped to global` is how a query
|
|
75
|
+
*opts out* and reaches past the tenant. As the documentation puts it: *"Making the
|
|
76
|
+
narrow case the default means the dangerous case is the one you have to write
|
|
77
|
+
down, rather than the one you get by forgetting."*
|
|
78
|
+
|
|
79
|
+
**The scope is a name, not a closed set.** `identity` and `global` are the two the
|
|
80
|
+
language documents, but the grammar accepts any name — what scopes exist follows
|
|
81
|
+
the identity model of whatever runs the document. A query declares at most one.
|
|
82
|
+
|
|
83
|
+
Treat every `scoped to global` in a review as a question to answer, not a detail.
|
|
84
|
+
|
|
85
|
+
### `observable`
|
|
86
|
+
|
|
87
|
+
`=> observable OverdueInvoicesReadModel[]` is a live read that keeps pushing;
|
|
88
|
+
without the marker a query is one-shot, which is the default and what most reads
|
|
89
|
+
are. The marker qualifies only *how* the result arrives, so it composes with `[]`,
|
|
90
|
+
`?`, `by`, `filter`, `authorize` and a `performer`. A screen binds to a live query
|
|
91
|
+
exactly as it binds to a one-shot one and gets updates for free.
|
|
92
|
+
|
|
93
|
+
### `performer`
|
|
94
|
+
|
|
95
|
+
A query is **complete without a performer** — it is realization metadata, not a
|
|
96
|
+
precondition. It takes a `file` reference or an inline code block, and is the
|
|
97
|
+
query's counterpart to a command's `handler`. `csharp` and `sql` are the two that
|
|
98
|
+
make sense here; the parser accepts any registered inline language
|
|
99
|
+
(`csharp`, `typescript`, `react`, `html`, `sql` are built in) and does not
|
|
100
|
+
reject a nonsensical one, so the choice is yours to get right.
|
|
101
|
+
|
|
102
|
+
## `screen` — three levels
|
|
103
|
+
|
|
104
|
+
**Level 1 — intent.** Data and actions; the tool generates the component.
|
|
105
|
+
|
|
106
|
+
```screenplay
|
|
107
|
+
screen InvoiceList
|
|
108
|
+
data InvoiceListReadModel[] via query ListInvoices
|
|
109
|
+
action RegisterInvoice
|
|
110
|
+
navigate to RegisterInvoiceScreen
|
|
111
|
+
action CancelInvoice
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Level 2 — structure.** Named sections, tables and summaries filling a template's
|
|
115
|
+
slots.
|
|
116
|
+
|
|
117
|
+
```screenplay
|
|
118
|
+
screen InvoiceDetails
|
|
119
|
+
template MasterDetail
|
|
120
|
+
sidebar
|
|
121
|
+
data InvoiceDetailsReadModel via query GetInvoice by invoiceId
|
|
122
|
+
summary InvoiceDetailsReadModel
|
|
123
|
+
field invoiceNumber label "Invoice #"
|
|
124
|
+
section actions
|
|
125
|
+
action CancelInvoice
|
|
126
|
+
main
|
|
127
|
+
section lineItems
|
|
128
|
+
table lineItems
|
|
129
|
+
column lineNumber label "#"
|
|
130
|
+
column quantity label "Qty"
|
|
131
|
+
on row-click navigate to InvoiceLineDetail by lineNumber
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Level 3 — inline code.** The surrounding Screenplay context supplies the typed
|
|
135
|
+
data contract; the inline block receives it as `Props`. Languages: `react`,
|
|
136
|
+
`typescript`, `html`, `csharp`.
|
|
137
|
+
|
|
138
|
+
Constructs: `title`, `data … via query … [by <param>]`, `action <Command>` with
|
|
139
|
+
`label` and `navigate to <Screen> [by <param>]`, `section <name>`,
|
|
140
|
+
`table <target>` with `column <property> [label]` and
|
|
141
|
+
`on row-click navigate to <Screen> [by <param>]`, `summary <ReadModel>` with
|
|
142
|
+
`field <property> label`, and `template <Name>` with slot bodies.
|
|
143
|
+
|
|
144
|
+
## How a bare name resolves
|
|
145
|
+
|
|
146
|
+
**Inside out:** the slice, then the enclosing feature, then the module, then the
|
|
147
|
+
document. The innermost match wins.
|
|
148
|
+
|
|
149
|
+
That rule exists because a generated document cannot make every name unique — one
|
|
150
|
+
real application declares 76 queries under 37 distinct names, with `All` appearing
|
|
151
|
+
21 times. Two sibling slices can each declare `All`, and each screen gets its own.
|
|
152
|
+
|
|
153
|
+
Reach across slices by qualifying with **any trailing part** of the scope:
|
|
154
|
+
|
|
155
|
+
```screenplay
|
|
156
|
+
screen OverviewScreen
|
|
157
|
+
data QueueReadModel[] via query Queue.All
|
|
158
|
+
data DeviationReadModel[] via query Preparation.Deviations.All
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Use the shortest unambiguous form. If a bare name matches two declarations equally
|
|
162
|
+
well, the compiler **warns and names the candidates** rather than picking one:
|
|
163
|
+
|
|
164
|
+
```text
|
|
165
|
+
Ambiguous query 'All' - it matches 2 declarations equally well
|
|
166
|
+
(Invoicing.Preparation.Queue, Invoicing.Preparation.Deviations); qualify it to say which
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
⚠️ Unresolved and ambiguous references are **warnings, not errors**, because a
|
|
170
|
+
name may resolve to something outside the document. Run with `--warnaserror` or a
|
|
171
|
+
screen can navigate to a screen that does not exist and the build stays green.
|
|
172
|
+
|
|
173
|
+
## Verify
|
|
174
|
+
|
|
175
|
+
- [ ] `screenplay <model> --warnaserror` reports zero errors and zero warnings.
|
|
176
|
+
- [ ] Every value the caller must not choose is a `from` parameter, not a `filter`.
|
|
177
|
+
- [ ] Every `scoped to global` is deliberate and defensible.
|
|
178
|
+
- [ ] `observable` is present exactly where the caller should see changes without
|
|
179
|
+
asking again.
|
|
180
|
+
- [ ] Each read model has exactly one builder and every field traces to an event.
|
|
181
|
+
- [ ] No screen reference is left ambiguous or unresolved.
|
|
182
|
+
|
|
183
|
+
## Route near misses
|
|
184
|
+
|
|
185
|
+
- How events fill the read model: `cratis-screenplay-projections`.
|
|
186
|
+
- Layouts, templates, forms, contributions, themes: `cratis-screenplay-ui-composition`.
|
|
187
|
+
- Asserting query results: `cratis-screenplay-specifications`.
|
|
188
|
+
- Deciding which read models exist: `cratis-screenplay-event-modeling`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill": "cratis-screenplay-read-surface",
|
|
3
|
+
"input": "Declare a Screenplay query and the screen that binds to it.",
|
|
4
|
+
"fixtures": [],
|
|
5
|
+
"assertions": [
|
|
6
|
+
{ "kind": "skill-contains", "value": "The tenant is the default and stays unstated" },
|
|
7
|
+
{ "kind": "skill-contains", "value": "belongs on a `from` parameter, never on a `filter`" },
|
|
8
|
+
{ "kind": "skill-contains", "value": "warnings, not errors" }
|
|
9
|
+
]
|
|
10
|
+
}
|