@reventlessdev/reventless-spec 3.0.0-alpha.169 → 3.0.0-alpha.171
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +17 -3
- package/package.json +5 -4
- package/run-rename-to-decision.mjs +3 -0
- package/src/AnsiStyle.res +3 -3
- package/src/CliArgs.res +1 -1
- package/src/LogPrefix.res +1 -1
- package/src/codemod/RenameToDecision.res +207 -0
- package/src/codemod/RenameToDecision.res.mjs +334 -0
- package/src/components/Aggregate.res +1 -1
- package/src/components/CapabilityManifest.res +1 -1
- package/src/components/ComponentKind.res +2 -2
- package/src/components/ComponentKind.res.mjs +1 -1
- package/src/components/DcbScopeInference.res +2 -2
- package/src/components/DcbTag.res +5 -5
- package/src/components/OutboundTranslationSlice.res +2 -2
- package/src/components/Plugin.res +14 -14
- package/src/components/Plugin.res.mjs +8 -8
- package/src/components/Sensitive.res +1 -1
- package/src/components/Snapshot.res +5 -5
- package/src/components/StateAnnotations.res +1 -1
- package/src/components/StateChangeSlice.res +6 -5
- package/src/components/StateViewSlice.res +3 -3
- package/src/components/TaggedUnion.res +1 -1
- package/src/generator/Codegen.res +5 -5
- package/src/generator/Codegen.res.mjs +1 -1
- package/src/generator/Discovery.res +3 -3
- package/src/generator/Pairing.res +47 -18
- package/src/generator/Pairing.res.mjs +41 -10
- package/src/lifecycle/CheckLifecycleModel.res +8 -7
- package/src/lifecycle/CheckLifecycleModel.res.mjs +2 -2
- package/src/semantic/Capabilities.res +2 -2
- package/src/semantic/CapabilityNeed.res +3 -3
- package/src/semantic/Color.res +8 -8
- package/src/semantic/IdentityProvider.res +1 -1
- package/src/semantic/Messaging.res +2 -2
- package/src/types/{Behavior.res → Decision.res} +10 -6
- package/src/types/Message.res +1 -1
- package/src/types/OwnerScope.res +5 -5
- package/src/types/RoleCoverage.res +6 -6
- package/src/types/RoleCoverage.res.mjs +6 -6
- package/src/types/Trait.res +1 -1
- /package/src/types/{Behavior.res.mjs → Decision.res.mjs} +0 -0
|
@@ -9,7 +9,7 @@ on no concurrent changes to the same entities.
|
|
|
9
9
|
Plan 02 splits the merged spec into two module types:
|
|
10
10
|
|
|
11
11
|
- `Spec` — types, identity, schemas. The structural contract.
|
|
12
|
-
- `
|
|
12
|
+
- `Decision` — `state`, `initialState`, `evolve`, `decide`. The state machine.
|
|
13
13
|
|
|
14
14
|
@example
|
|
15
15
|
```rescript
|
|
@@ -43,7 +43,7 @@ let decide = (state, command) => switch command {
|
|
|
43
43
|
*/
|
|
44
44
|
/**
|
|
45
45
|
The lean Spec for a StateChangeSlice — types and schemas. State and
|
|
46
|
-
state-evolution functions live in the sibling `
|
|
46
|
+
state-evolution functions live in the sibling `Decision` module type. A slice has
|
|
47
47
|
no identity of its own: it decides about the one its partition field carries.
|
|
48
48
|
*/
|
|
49
49
|
module type Spec = {
|
|
@@ -119,12 +119,13 @@ module type Spec = {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
-
The
|
|
122
|
+
The Decision — pure state machine that decides commands and folds events, in
|
|
123
|
+
the shape of the decider pattern. Its file is `<Slice>_Decision.res`.
|
|
123
124
|
|
|
124
125
|
`module Spec: Spec` shares the lean Spec's types so `evolve` references
|
|
125
126
|
`Spec.consumedEvent`, `decide` references `Spec.command`/`Spec.event`/`Spec.error`.
|
|
126
127
|
*/
|
|
127
|
-
module type
|
|
128
|
+
module type Decision = {
|
|
128
129
|
module Spec: Spec
|
|
129
130
|
|
|
130
131
|
/**
|
|
@@ -148,6 +149,6 @@ module type Behavior = {
|
|
|
148
149
|
*/
|
|
149
150
|
let decide: (state, Spec.command) => result<array<Spec.event>, Spec.error>
|
|
150
151
|
|
|
151
|
-
/** File URL of this
|
|
152
|
+
/** File URL of this Decision module (`import.meta.url`). */
|
|
152
153
|
let moduleUrl: string
|
|
153
154
|
}
|
|
@@ -53,11 +53,11 @@ module type Spec = {
|
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
What a row is keyed by. `Id.StringPure` (a plain `string`) unless the view
|
|
56
|
-
declares its identity — `module
|
|
56
|
+
declares its identity — `module Id = OrderId` — in which case a projection
|
|
57
57
|
that keys a row by another entity's id does not compile. Auto-injected by
|
|
58
58
|
`@@reventless.spec` in a `StateView/` folder.
|
|
59
59
|
*/
|
|
60
|
-
module
|
|
60
|
+
module Id: Id.T
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
Events this view slice projects. Only needs the fields required for the projection —
|
|
@@ -125,7 +125,7 @@ module type Projection = {
|
|
|
125
125
|
Receives the event wrapped in a `consumed` envelope (event + `meta` +
|
|
126
126
|
`recordedAt`); only events declared in `Spec.consumedEvent` — no wildcard needed.
|
|
127
127
|
*/
|
|
128
|
-
let project: consumed<Spec.consumedEvent> => array<Projection.action<Spec.
|
|
128
|
+
let project: consumed<Spec.consumedEvent> => array<Projection.action<Spec.Id.t, Spec.state>>
|
|
129
129
|
|
|
130
130
|
/** File URL of this Projection module (`import.meta.url`). */
|
|
131
131
|
let moduleUrl: string
|
|
@@ -19,7 +19,7 @@ the declaration by `named` — the ppx does it for a variant a state record uses
|
|
|
19
19
|
and a framework type carrying a union does it beside its `Semantic.mark`.
|
|
20
20
|
|
|
21
21
|
A union with no name is not emitted as one. It falls through to the IR's
|
|
22
|
-
`Unknown`, which is a `String` in the SDL — the
|
|
22
|
+
`Unknown`, which is a `String` in the SDL — the behavior that predates this
|
|
23
23
|
module — except that it is now reported at deploy time instead of silently.
|
|
24
24
|
*/
|
|
25
25
|
let unionNameId: S.Metadata.Id.t<string> = S.Metadata.Id.make(
|
|
@@ -67,13 +67,13 @@ let renderAutomationSlices = (stems: array<string>): array<string> =>
|
|
|
67
67
|
)
|
|
68
68
|
|
|
69
69
|
let renderAggregates = (aggregates: array<Pairing.aggregateDef>): array<string> =>
|
|
70
|
-
aggregates->Array.flatMap(({spec,
|
|
70
|
+
aggregates->Array.flatMap(({spec, decision, eventMappings, isAsync}) => {
|
|
71
71
|
let em = eventMappings->Option.getOr("ReventlessInfra.NoEventMappings.Make(" ++ spec ++ ")")
|
|
72
72
|
let factory = isAsync ? "MakeAsync" : "Make"
|
|
73
73
|
[
|
|
74
74
|
" module " ++ spec ++ "Aggregate = Platform.Aggregate." ++ factory ++ "(",
|
|
75
75
|
" " ++ spec ++ ",",
|
|
76
|
-
" " ++
|
|
76
|
+
" " ++ decision ++ ",",
|
|
77
77
|
" " ++ em ++ ",",
|
|
78
78
|
" )",
|
|
79
79
|
]
|
|
@@ -159,7 +159,7 @@ let renderExtensions = (extensions: array<string>): array<string> =>
|
|
|
159
159
|
" module " ++ stem ++ " = Platform.Extension.Make(" ++ stem ++ ".Mapping)"
|
|
160
160
|
)
|
|
161
161
|
|
|
162
|
-
// AWS-specific renderers were removed in
|
|
162
|
+
// AWS-specific renderers were removed in favor of the canonical ones — the
|
|
163
163
|
// AWS Plugin.res now emits `open <Namespace>` so bare stems resolve, and the
|
|
164
164
|
// AWS Platform.{Aggregate,ReadModel}.Make functors delegate to the
|
|
165
165
|
// `*_Builder_Single` strategy under the hood (see reventless-aws/src/Platform.res).
|
|
@@ -630,7 +630,7 @@ let renderComposition = (
|
|
|
630
630
|
// Header
|
|
631
631
|
lines->Array.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.")
|
|
632
632
|
// Federation override URL — empty/unset means Auto UI renders every fragment.
|
|
633
|
-
// Read once at module init; same env var is
|
|
633
|
+
// Read once at module init; same env var is honored by the AWS wrapper.
|
|
634
634
|
lines->Array.push("")
|
|
635
635
|
lines->Array.push(
|
|
636
636
|
"@val external uiBundleUrl: option<string> = \"process.env." ++
|
|
@@ -877,7 +877,7 @@ let render = (
|
|
|
877
877
|
validateSliceTargets(~resolved)
|
|
878
878
|
// Chapter map, keyed by each component's spec stem (= its `Spec.name` for every
|
|
879
879
|
// graph-node kind, which is how `Plugin_Structure` looks the chapter up). Filtered
|
|
880
|
-
// to the actual component stems so body files (`
|
|
880
|
+
// to the actual component stems so body files (`_Decision`, `_Projections`, …) —
|
|
881
881
|
// which `Discovery` also surfaces and which share their component's chapter — don't
|
|
882
882
|
// leak noise entries into the generated call. Tasks / extensions / extension points
|
|
883
883
|
// carry no chapter field, so they are excluded too.
|
|
@@ -28,7 +28,7 @@ type discoveredFile = {
|
|
|
28
28
|
let folderToComponentType = ComponentKind.folderToKind
|
|
29
29
|
|
|
30
30
|
// The intra-plugin grouping band ("chapter") a file belongs to, derived from its
|
|
31
|
-
// path relative to `src/`: the first directory segment that is not a
|
|
31
|
+
// path relative to `src/`: the first directory segment that is not a recognized
|
|
32
32
|
// kind-folder. `src/<Chapter>/<Kind>/<Component>.res` → `Some("<Chapter>")`; a file
|
|
33
33
|
// directly under a kind-folder (`src/<Kind>/<Component>.res`) or at the src root →
|
|
34
34
|
// `None`. Uses the single-source `ComponentKind.isKindFolder`, so a chapter read
|
|
@@ -52,7 +52,7 @@ let chapterOf = (relPath: string): option<string> => {
|
|
|
52
52
|
// Deduplicated (stem → chapter) pairs across the discovered files, sorted by stem
|
|
53
53
|
// for deterministic codegen. Only files that carry a chapter are included; stems are
|
|
54
54
|
// unique within a plugin (validated), so keying by stem cannot collide. Body files
|
|
55
|
-
// (`
|
|
55
|
+
// (`_Decision`, `_Projections`, …) share their component's chapter but are never
|
|
56
56
|
// looked up by `Spec.name`, so their presence is harmless.
|
|
57
57
|
let chaptersByStem = (discovered: array<discoveredFile>): array<(string, string)> => {
|
|
58
58
|
let byStem: Dict.t<string> = Dict.make()
|
|
@@ -116,7 +116,7 @@ let isSkipped = (stem: string): bool =>
|
|
|
116
116
|
// `_EventMappings` and `_Mappings` files in per-entity Aggregate/ folders
|
|
117
117
|
// are picked up by the dedicated [Pairing.findEventMappings] walker;
|
|
118
118
|
// treating them as Aggregate specs would hunt for a non-existent
|
|
119
|
-
// `
|
|
119
|
+
// `_EventMappings_Decision` / `_Mappings_Decision`.
|
|
120
120
|
stem->String.endsWith("_EventMappings") ||
|
|
121
121
|
stem->String.endsWith("_Mappings")
|
|
122
122
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Applies pairing rules to discovered files.
|
|
2
|
-
// - Aggregates: pairs Foo +
|
|
2
|
+
// - Aggregates: pairs Foo + Foo_Decision; finds Foo_EventMappings if present
|
|
3
3
|
// - ReadModels: pairs FooReadModel + FooProjections; extracts mapping module names from projections file
|
|
4
4
|
// - Slices (Plan 02): pair X + X_<Kind> where Kind is the implementation
|
|
5
5
|
// suffix matching the slice's folder. The slice arrays carry only spec
|
|
6
6
|
// stems; the corresponding impl stem is `<stem>_<implSuffix>`.
|
|
7
|
-
// * StateChangeSlice / StateViewSlice →
|
|
7
|
+
// * StateChangeSlice / StateViewSlice → Decision / Projection
|
|
8
8
|
// * AutomationSlice → Automation
|
|
9
9
|
// * In/OutboundTranslationSlice → Translation
|
|
10
10
|
// - ExtensionPoints: groups by epGroup, counts mappings, selects Make variant
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Implementation-file suffix per slice kind. Used to filter impl files out
|
|
13
13
|
// of the slice arrays (they're paired with their spec stems via this
|
|
14
14
|
// suffix at codegen time) and to compute the impl module reference.
|
|
15
|
-
let implSuffixForStateChange = "
|
|
15
|
+
let implSuffixForStateChange = "_Decision"
|
|
16
16
|
let implSuffixForStateView = "_Projection"
|
|
17
17
|
let implSuffixForAutomation = "_Automation"
|
|
18
18
|
let implSuffixForTranslation = "_Translation"
|
|
@@ -27,7 +27,7 @@ let isImplStem = (stem: string): bool =>
|
|
|
27
27
|
stem->String.endsWith(implSuffixForTranslation) ||
|
|
28
28
|
stem->String.endsWith(mappingsSuffixForAutomation)
|
|
29
29
|
|
|
30
|
-
type aggregateDef = {spec: string,
|
|
30
|
+
type aggregateDef = {spec: string, decision: string, eventMappings: option<string>, isAsync: bool}
|
|
31
31
|
// readModelDef pairs a ReadModel spec with its sibling `_Projections.res`
|
|
32
32
|
// file. Codegen emits `Platform.ReadModel.Make(<spec>, <projections>)` and
|
|
33
33
|
// expects the projections file to declare `let mappings` via
|
|
@@ -252,7 +252,37 @@ let groupExtensionPoints = (epFiles: array<Discovery.discoveredFile>): array<ext
|
|
|
252
252
|
extensionPoints
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// The logic file's former name. One beside its spec is refused rather than
|
|
256
|
+
// read as a component of its own.
|
|
257
|
+
let retiredSuffix = "_Behavior"
|
|
258
|
+
|
|
259
|
+
let retiredBehaviorFile = (discovered: array<Discovery.discoveredFile>): option<
|
|
260
|
+
Discovery.discoveredFile,
|
|
261
|
+
> => {
|
|
262
|
+
let stems = discovered->Array.map(({stem}) => stem)
|
|
263
|
+
discovered->Array.find(({stem}) =>
|
|
264
|
+
stem->String.endsWith(retiredSuffix) ||
|
|
265
|
+
(stem->String.endsWith("Behavior") &&
|
|
266
|
+
stems->Array.includes(stem->String.slice(~start=0, ~end=String.length(stem) - 8)))
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let refuseBehaviorFiles = (discovered: array<Discovery.discoveredFile>) =>
|
|
271
|
+
switch retiredBehaviorFile(discovered) {
|
|
272
|
+
| None => ()
|
|
273
|
+
| Some({stem, relPath}) =>
|
|
274
|
+
let spec =
|
|
275
|
+
stem->String.endsWith(retiredSuffix)
|
|
276
|
+
? stem->String.slice(~start=0, ~end=String.length(stem) - 9)
|
|
277
|
+
: stem->String.slice(~start=0, ~end=String.length(stem) - 8)
|
|
278
|
+
JsError.throwWithMessage(
|
|
279
|
+
`Generator: ${relPath} uses the former name. The logic file is now ` ++
|
|
280
|
+
`${spec}_Decision.res, annotated @@reventless.decision: rename it ` ++ `(or run reventless-rename-to-decision).`,
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
255
284
|
let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): resolved => {
|
|
285
|
+
refuseBehaviorFiles(discovered)
|
|
256
286
|
let eventMappings = findEventMappings(~srcDir)
|
|
257
287
|
|
|
258
288
|
let stateChangeSlices = []
|
|
@@ -270,7 +300,7 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
|
|
|
270
300
|
let stateChangeSliceRelPaths: Dict.t<string> = Dict.make()
|
|
271
301
|
// Shared by StateViewSlice and StateViewSliceStream (stems are unique).
|
|
272
302
|
let stateViewSliceRelPaths: Dict.t<string> = Dict.make()
|
|
273
|
-
let
|
|
303
|
+
let aggregateDecisions: array<string> = []
|
|
274
304
|
let readModelStems: array<string> = []
|
|
275
305
|
// Stream-enabled read model spec stems (the `ReadModelStream/` folder).
|
|
276
306
|
let readModelStreamStems: array<string> = []
|
|
@@ -282,7 +312,7 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
|
|
|
282
312
|
let extensions = []
|
|
283
313
|
|
|
284
314
|
// Classify discovered files by component type. For the five slice
|
|
285
|
-
// families, impl files (
|
|
315
|
+
// families, impl files (X_Decision / X_Projection / X_Automation /
|
|
286
316
|
// X_Translation) are filtered out: they're paired with their spec stems
|
|
287
317
|
// at codegen time via the `_<ImplKind>` suffix convention.
|
|
288
318
|
discovered->Array.forEach(({stem, componentType, epGroup, relPath}) => {
|
|
@@ -318,8 +348,8 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
|
|
|
318
348
|
outboundTranslationSliceRelPaths->Dict.set(stem, relPath)
|
|
319
349
|
}
|
|
320
350
|
| Aggregate =>
|
|
321
|
-
if stem->String.endsWith("
|
|
322
|
-
|
|
351
|
+
if stem->String.endsWith("Decision") {
|
|
352
|
+
aggregateDecisions->Array.push(stem)
|
|
323
353
|
} else {
|
|
324
354
|
aggregateSpecs->Array.push(stem)
|
|
325
355
|
aggregateSpecRelPaths->Dict.set(stem, relPath)
|
|
@@ -343,30 +373,29 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
|
|
|
343
373
|
})
|
|
344
374
|
|
|
345
375
|
// ── Aggregate pairing ──────────────────────────────────────────────────────
|
|
346
|
-
// Tries
|
|
347
|
-
// to the legacy `<Spec>Behavior` (no underscore).
|
|
376
|
+
// Tries `<Spec>_Decision` first, then `<Spec>Decision` (no underscore).
|
|
348
377
|
let aggregates =
|
|
349
378
|
aggregateSpecs
|
|
350
379
|
->sortedStems
|
|
351
380
|
->Array.filterMap(spec => {
|
|
352
|
-
let underscored = spec ++ "
|
|
353
|
-
let bare = spec ++ "
|
|
354
|
-
let
|
|
381
|
+
let underscored = spec ++ "_Decision"
|
|
382
|
+
let bare = spec ++ "Decision"
|
|
383
|
+
let decisionStem = if aggregateDecisions->Array.includes(underscored) {
|
|
355
384
|
Some(underscored)
|
|
356
|
-
} else if
|
|
385
|
+
} else if aggregateDecisions->Array.includes(bare) {
|
|
357
386
|
Some(bare)
|
|
358
387
|
} else {
|
|
359
388
|
None
|
|
360
389
|
}
|
|
361
|
-
switch
|
|
362
|
-
| Some(
|
|
390
|
+
switch decisionStem {
|
|
391
|
+
| Some(decision) =>
|
|
363
392
|
let isAsync = switch Dict.get(aggregateSpecRelPaths, spec) {
|
|
364
393
|
| None => false
|
|
365
394
|
| Some(relPath) => hasAsyncAttribute(NodePath.join([srcDir, relPath]))
|
|
366
395
|
}
|
|
367
396
|
Some({
|
|
368
397
|
spec,
|
|
369
|
-
|
|
398
|
+
decision,
|
|
370
399
|
eventMappings: Dict.get(eventMappings, spec),
|
|
371
400
|
isAsync,
|
|
372
401
|
})
|
|
@@ -386,7 +415,7 @@ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): re
|
|
|
386
415
|
// ── ReadModel pairing ──────────────────────────────────────────────────────
|
|
387
416
|
// Pairs spec `<Plural>` with impl `<Plural>_Projections.res`. The legacy
|
|
388
417
|
// unpaired forms (`<Plural>ReadModel.res` / `<Plural>Projections.res`) are
|
|
389
|
-
// no longer
|
|
418
|
+
// no longer recognized — see PR3-5 of the close-ppx-gaps-and-unify-naming
|
|
390
419
|
// plan for the migration; downstream codebases should rename to the
|
|
391
420
|
// underscored form.
|
|
392
421
|
let pairReadModel = (~stream: bool, rm: string): option<readModelDef> => {
|
|
@@ -4,9 +4,10 @@ import * as Nodefs from "node:fs";
|
|
|
4
4
|
import * as Nodepath from "node:path";
|
|
5
5
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
7
8
|
import * as Generator_Node$Reventless from "./Generator_Node.res.mjs";
|
|
8
9
|
|
|
9
|
-
let implSuffixForStateChange = "
|
|
10
|
+
let implSuffixForStateChange = "_Decision";
|
|
10
11
|
|
|
11
12
|
let implSuffixForStateView = "_Projection";
|
|
12
13
|
|
|
@@ -197,7 +198,34 @@ function groupExtensionPoints(epFiles) {
|
|
|
197
198
|
return extensionPoints;
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
let retiredSuffix = "_Behavior";
|
|
202
|
+
|
|
203
|
+
function retiredBehaviorFile(discovered) {
|
|
204
|
+
let stems = discovered.map(param => param.stem);
|
|
205
|
+
return discovered.find(param => {
|
|
206
|
+
let stem = param.stem;
|
|
207
|
+
if (stem.endsWith(retiredSuffix)) {
|
|
208
|
+
return true;
|
|
209
|
+
} else if (stem.endsWith("Behavior")) {
|
|
210
|
+
return stems.includes(stem.slice(0, stem.length - 8 | 0));
|
|
211
|
+
} else {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function refuseBehaviorFiles(discovered) {
|
|
218
|
+
let match = retiredBehaviorFile(discovered);
|
|
219
|
+
if (match === undefined) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
let stem = match.stem;
|
|
223
|
+
let spec = stem.endsWith(retiredSuffix) ? stem.slice(0, stem.length - 9 | 0) : stem.slice(0, stem.length - 8 | 0);
|
|
224
|
+
Stdlib_JsError.throwWithMessage(`Generator: ` + match.relPath + ` uses the former name. The logic file is now ` + (spec + `_Decision.res, annotated @@reventless.decision: rename it `) + `(or run reventless-rename-to-decision).`);
|
|
225
|
+
}
|
|
226
|
+
|
|
200
227
|
function resolve(discovered, srcDir) {
|
|
228
|
+
refuseBehaviorFiles(discovered);
|
|
201
229
|
let eventMappings = findEventMappings(srcDir);
|
|
202
230
|
let stateChangeSlices = [];
|
|
203
231
|
let stateViewSlices = [];
|
|
@@ -212,7 +240,7 @@ function resolve(discovered, srcDir) {
|
|
|
212
240
|
let aggregateSpecRelPaths = {};
|
|
213
241
|
let stateChangeSliceRelPaths = {};
|
|
214
242
|
let stateViewSliceRelPaths = {};
|
|
215
|
-
let
|
|
243
|
+
let aggregateDecisions = [];
|
|
216
244
|
let readModelStems = [];
|
|
217
245
|
let readModelStreamStems = [];
|
|
218
246
|
let projectionsByRelPath = {};
|
|
@@ -273,8 +301,8 @@ function resolve(discovered, srcDir) {
|
|
|
273
301
|
return;
|
|
274
302
|
}
|
|
275
303
|
case "Aggregate" :
|
|
276
|
-
if (stem.endsWith("
|
|
277
|
-
|
|
304
|
+
if (stem.endsWith("Decision")) {
|
|
305
|
+
aggregateDecisions.push(stem);
|
|
278
306
|
} else {
|
|
279
307
|
aggregateSpecs.push(stem);
|
|
280
308
|
aggregateSpecRelPaths[stem] = relPath;
|
|
@@ -311,17 +339,17 @@ function resolve(discovered, srcDir) {
|
|
|
311
339
|
}
|
|
312
340
|
});
|
|
313
341
|
let aggregates = Stdlib_Array.filterMap(sortedStems(aggregateSpecs), spec => {
|
|
314
|
-
let underscored = spec + "
|
|
315
|
-
let bare = spec + "
|
|
316
|
-
let
|
|
317
|
-
|
|
342
|
+
let underscored = spec + "_Decision";
|
|
343
|
+
let bare = spec + "Decision";
|
|
344
|
+
let decisionStem = aggregateDecisions.includes(underscored) ? underscored : (
|
|
345
|
+
aggregateDecisions.includes(bare) ? bare : undefined
|
|
318
346
|
);
|
|
319
|
-
if (
|
|
347
|
+
if (decisionStem !== undefined) {
|
|
320
348
|
let relPath = aggregateSpecRelPaths[spec];
|
|
321
349
|
let isAsync = relPath !== undefined ? hasFileAttribute(Nodepath.join(srcDir, relPath), "@@reventless.async") : false;
|
|
322
350
|
return {
|
|
323
351
|
spec: spec,
|
|
324
|
-
|
|
352
|
+
decision: decisionStem,
|
|
325
353
|
eventMappings: eventMappings[spec],
|
|
326
354
|
isAsync: isAsync
|
|
327
355
|
};
|
|
@@ -410,6 +438,9 @@ export {
|
|
|
410
438
|
hasSystemCallableAttribute,
|
|
411
439
|
effectiveSpecName,
|
|
412
440
|
groupExtensionPoints,
|
|
441
|
+
retiredSuffix,
|
|
442
|
+
retiredBehaviorFile,
|
|
443
|
+
refuseBehaviorFiles,
|
|
413
444
|
resolve,
|
|
414
445
|
}
|
|
415
446
|
/* node:fs Not a pure module */
|
|
@@ -3,7 +3,7 @@ The lifecycle model each example's own scenarios describe, and what it says abou
|
|
|
3
3
|
the edges each command declares beside them.
|
|
4
4
|
|
|
5
5
|
`Moves([Placed], Shipped)` is authored, and until now nothing compared it to
|
|
6
|
-
|
|
6
|
+
behavior. The compiler resolves the state constructors and the platform checks
|
|
7
7
|
they belong to the linked view — both much weaker properties than agreement: a
|
|
8
8
|
command can declare `Moves([Deactivated], Active)` while its `decide` accepts an
|
|
9
9
|
active row, or emit an event the view folds into something else, and every static
|
|
@@ -595,7 +595,8 @@ let lifecycleMapFor = (
|
|
|
595
595
|
|
|
596
596
|
// ── Rule 2 and 3: the relation a command's own scenarios describe ───────────
|
|
597
597
|
|
|
598
|
-
|
|
598
|
+
// `Rejected`: the decision rejected the command, the sidecar's `"error"`.
|
|
599
|
+
type outcome = Emitted | NoChange | Rejected
|
|
599
600
|
|
|
600
601
|
type observation = {
|
|
601
602
|
title: string,
|
|
@@ -646,7 +647,7 @@ let outcomeOf = (s: scenario): outcome =>
|
|
|
646
647
|
// `thenNoEvent`, and the empty `then` an older sidecar wrote for the same
|
|
647
648
|
// thing. Accepted, and nothing happened.
|
|
648
649
|
| "noEvent" | "" => NoChange
|
|
649
|
-
| "error" =>
|
|
650
|
+
| "error" => Rejected
|
|
650
651
|
// A `then` this harvest has no reading for — a side effect, a published
|
|
651
652
|
// command. Not an effect on this row either way.
|
|
652
653
|
| _ => NoChange
|
|
@@ -694,7 +695,7 @@ let observe = (
|
|
|
694
695
|
|
|
695
696
|
scenarios->Array.filterMap(s =>
|
|
696
697
|
switch s.whenElements->Array.get(0) {
|
|
697
|
-
// `
|
|
698
|
+
// `thenForbidden`: the caller did not own the row, which says nothing about
|
|
698
699
|
// the states the command is legal in.
|
|
699
700
|
| Some(_) if s.thenKind == "forbidden" => None
|
|
700
701
|
| Some(command) if s.whenKind == "command" =>
|
|
@@ -739,7 +740,7 @@ let deriveCommands = (
|
|
|
739
740
|
mine->Array.filterMap(o => o.outcome == Emitted || o.from == noRow ? None : Some(o.from)),
|
|
740
741
|
),
|
|
741
742
|
refusedStates: sortedUnique(
|
|
742
|
-
mine->Array.filterMap(o => o.outcome ==
|
|
743
|
+
mine->Array.filterMap(o => o.outcome == Rejected && o.from != noRow ? Some(o.from) : None),
|
|
743
744
|
),
|
|
744
745
|
targets: sortedUnique(
|
|
745
746
|
effective->Array.filterMap(o => o.to == o.from || o.to == noRow ? None : Some(o.to)),
|
|
@@ -935,10 +936,10 @@ let compare = (
|
|
|
935
936
|
// effect somewhere agrees with that rather than narrowing it — the corpus
|
|
936
937
|
// covers the states somebody wrote a scenario for, and silence about the
|
|
937
938
|
// rest is not refusal. What DOES refute the claim is a state the command was
|
|
938
|
-
// exercised in and **refused**: that is the switch and the
|
|
939
|
+
// exercised in and **refused**: that is the switch and the behavior
|
|
939
940
|
// disagreeing about the same row.
|
|
940
941
|
//
|
|
941
|
-
// 🚨 **
|
|
942
|
+
// 🚨 **Rejected, not merely inert.** `Unrestricted` claims the command is
|
|
942
943
|
// never refused — it does not claim the command always emits. A state where a
|
|
943
944
|
// scenario shows it accepted and silent AGREES with that: returning `Ok([])`
|
|
944
945
|
// for a command that would change nothing is this codebase's idempotency
|
|
@@ -470,7 +470,7 @@ function outcomeOf(s) {
|
|
|
470
470
|
let match = s.thenKind;
|
|
471
471
|
switch (match) {
|
|
472
472
|
case "error" :
|
|
473
|
-
return "
|
|
473
|
+
return "Rejected";
|
|
474
474
|
case "event" :
|
|
475
475
|
return "Emitted";
|
|
476
476
|
default:
|
|
@@ -561,7 +561,7 @@ function deriveCommands(component, observations, labelled) {
|
|
|
561
561
|
}
|
|
562
562
|
})),
|
|
563
563
|
refusedStates: sortedUnique(Stdlib_Array.filterMap(mine, o => {
|
|
564
|
-
if (o.outcome === "
|
|
564
|
+
if (o.outcome === "Rejected" && o.from !== noRow) {
|
|
565
565
|
return o.from;
|
|
566
566
|
}
|
|
567
567
|
})),
|
|
@@ -41,9 +41,9 @@ type t = {
|
|
|
41
41
|
/**
|
|
42
42
|
The capability set of a platform that provisions nothing.
|
|
43
43
|
|
|
44
|
-
Every capability here answers `Unavailable`, which is a *
|
|
44
|
+
Every capability here answers `Unavailable`, which is a *modeled* outcome:
|
|
45
45
|
`translate` maps it to `Error`, the item is retried and the sweep surfaces it.
|
|
46
|
-
That is the
|
|
46
|
+
That is the behavior wanted for a deployment that simply has no geocoder — the
|
|
47
47
|
work stays queued and visible rather than being written off as a verdict on the
|
|
48
48
|
address, which is what `NoMatch` would mean.
|
|
49
49
|
|
|
@@ -47,7 +47,7 @@ let toString = (need: t): string =>
|
|
|
47
47
|
|
|
48
48
|
/** The inverse, for readers of a persisted structure or a committed manifest.
|
|
49
49
|
`None` for a capability this build does not know — a newer plugin's need is
|
|
50
|
-
reported as
|
|
50
|
+
reported as unrecognized, never silently dropped into a known arm. */
|
|
51
51
|
let fromString = (name: string): option<t> =>
|
|
52
52
|
switch name {
|
|
53
53
|
| "Geocoding" => Some(Geocoding)
|
|
@@ -65,13 +65,13 @@ Declared capability needs a platform does not provision.
|
|
|
65
65
|
|
|
66
66
|
`declared` is `pluginStructure.requiredCapabilities` as persisted: `(capability,
|
|
67
67
|
component)` pairs whose capability is a string, because a structure is replayed
|
|
68
|
-
from an event log. A name this build does not
|
|
68
|
+
from an event log. A name this build does not recognize is **skipped** — it
|
|
69
69
|
belongs to a newer framework, and refusing a deploy over a capability this code
|
|
70
70
|
cannot evaluate would be a guess dressed as a check.
|
|
71
71
|
|
|
72
72
|
Only what was declared. A plugin that declares nothing is unaffected: an
|
|
73
73
|
unprovisioned capability it never named degrades exactly as it always has, which
|
|
74
|
-
is the
|
|
74
|
+
is the modeled outcome for a deployment that simply does not have one.
|
|
75
75
|
*/
|
|
76
76
|
let unmet = (~declared: array<(string, string)>, ~provisioned: array<t>): array<unmet> =>
|
|
77
77
|
declared->Array.filterMap(((capability, component)) =>
|
package/src/semantic/Color.res
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
Marks a `string` field as a
|
|
2
|
+
Marks a `string` field as a color, written as a hex triplet.
|
|
3
3
|
|
|
4
4
|
## Why hex only
|
|
5
5
|
|
|
6
6
|
A field carrying this semantic is rendered as a swatch by setting the value as a
|
|
7
|
-
CSS `background`. CSS accepts far more than a
|
|
8
|
-
`var(…)` — so "any CSS colour" would make a
|
|
7
|
+
CSS `background`. CSS accepts far more than a color there — `url(…)`, gradients,
|
|
8
|
+
`var(…)` — so "any CSS colour" would make a color field a way to write arbitrary
|
|
9
9
|
CSS into a log that cannot be edited afterwards. Hex is the form that is a
|
|
10
|
-
|
|
10
|
+
color and nothing else, and it is what a color picker emits anyway.
|
|
11
11
|
|
|
12
|
-
Named
|
|
12
|
+
Named colors (`rebeccapurple`) and functional forms (`rgb(…)`, `oklch(…)`) are
|
|
13
13
|
rejected for the same reason, not because they are worse notation.
|
|
14
14
|
|
|
15
15
|
## The grammar
|
|
@@ -26,7 +26,7 @@ full triplet, and the triplet with alpha. Case is not significant.
|
|
|
26
26
|
})
|
|
27
27
|
```
|
|
28
28
|
*/
|
|
29
|
-
/** The
|
|
29
|
+
/** The color's representation. Transparent `string`; see `Email.t`. */
|
|
30
30
|
type t = string
|
|
31
31
|
|
|
32
32
|
external unsafe: string => t = "%identity"
|
|
@@ -34,7 +34,7 @@ external toString: t => string = "%identity"
|
|
|
34
34
|
|
|
35
35
|
let hex = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
|
|
36
36
|
|
|
37
|
-
/** Validate a raw string as a hex
|
|
37
|
+
/** Validate a raw string as a hex color, saying why when it is not one. */
|
|
38
38
|
let fromString = (raw: string): result<t, string> =>
|
|
39
39
|
if hex->RegExp.test(raw) {
|
|
40
40
|
Ok(raw)
|
|
@@ -46,5 +46,5 @@ let fromString = (raw: string): result<t, string> =>
|
|
|
46
46
|
)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** The sury schema for a
|
|
49
|
+
/** The sury schema for a color field. Use with `@s.matches(Reventless.Color.schema)`. */
|
|
50
50
|
let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.color, ~check=fromString)
|
|
@@ -38,7 +38,7 @@ surface. Collapsing them either loses accounts to a transient blip or retries
|
|
|
38
38
|
forever against a password policy.
|
|
39
39
|
*/
|
|
40
40
|
type failure =
|
|
41
|
-
/** The contact is already a principal. A
|
|
41
|
+
/** The contact is already a principal. A modeled answer, not an exception —
|
|
42
42
|
which is what a silent idempotent sign-up throws away. */
|
|
43
43
|
| Conflict
|
|
44
44
|
/** The provider is down or unreachable. The domain fact stands; retry. */
|
|
@@ -142,7 +142,7 @@ type message = {subject?: string, body: string}
|
|
|
142
142
|
|
|
143
143
|
/** The provider accepted the message. `ref` is its own id for it — what a
|
|
144
144
|
support conversation about a missing message is conducted with, and the only
|
|
145
|
-
thing a caller can record that the provider will
|
|
145
|
+
thing a caller can record that the provider will recognize. */
|
|
146
146
|
type receipt = {ref: string}
|
|
147
147
|
|
|
148
148
|
/**
|
|
@@ -203,7 +203,7 @@ type send = (~recipient: recipient, ~message: message) => promise<result<receipt
|
|
|
203
203
|
The capability as a slice receives it: what can be attempted, and how.
|
|
204
204
|
|
|
205
205
|
`channels` is here because a recipient cannot be offered a choice the deployment
|
|
206
|
-
cannot
|
|
206
|
+
cannot honor. A platform provisions email only, or email and SMS; a preference
|
|
207
207
|
centre that listed all three would collect a subscription every send then answers
|
|
208
208
|
`UnsupportedChannel` for. Published rather than inferred from a failed send,
|
|
209
209
|
because discovering a channel by failing on it costs a real message.
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Lives in reventless-spec so application domain code can define
|
|
1
|
+
// Decision module type for an aggregate's write-side logic.
|
|
2
|
+
// Lives in reventless-spec so application domain code can define decisions
|
|
3
3
|
// without importing the reventless implementation package.
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
Module type that application code must implement to define aggregate
|
|
6
|
+
Module type that application code must implement to define an aggregate's
|
|
7
|
+
write-side logic: whether a command goes through, and what it emits.
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
It implements the *decider* pattern: `initialState`, `evolve` and `decide`.
|
|
10
|
+
The file is `<Entity>_Decision.res`, annotated `@@reventless.decision`.
|
|
11
|
+
|
|
12
|
+
A `Decision.T` module contains the `Spec` (command / event / error types), the
|
|
9
13
|
aggregate `state`, and two pure functions plus an initial state value:
|
|
10
14
|
|
|
11
15
|
- `initialState` — the starting state before any events (represents "not yet created")
|
|
@@ -14,7 +18,7 @@ aggregate `state`, and two pure functions plus an initial state value:
|
|
|
14
18
|
|
|
15
19
|
@example
|
|
16
20
|
```rescript
|
|
17
|
-
//
|
|
21
|
+
// Category_Decision.res
|
|
18
22
|
module Spec = Category
|
|
19
23
|
|
|
20
24
|
@schema
|
|
@@ -73,7 +77,7 @@ module type T = {
|
|
|
73
77
|
let decide: (state, Spec.command) => result<array<Spec.event>, Spec.error>
|
|
74
78
|
|
|
75
79
|
/** Persisted-snapshot configuration (docs/plans/done/aggregate-snapshotting.md).
|
|
76
|
-
`None` — the default, auto-injected by `@@reventless.
|
|
80
|
+
`None` — the default, auto-injected by `@@reventless.decision` — keeps
|
|
77
81
|
full replay; `Some({interval, stateSchema})` writes a keep-one snapshot
|
|
78
82
|
every `interval` events and seeds cold replays from the latest one.
|
|
79
83
|
Enable with `@@reventless.snapshots(<interval>)` (requires
|
package/src/types/Message.res
CHANGED
|
@@ -76,7 +76,7 @@ type meta = {
|
|
|
76
76
|
/**
|
|
77
77
|
Context passed into command handlers, carrying the aggregate ID and envelope metadata.
|
|
78
78
|
|
|
79
|
-
Passed as the second argument to `
|
|
79
|
+
Passed as the second argument to `Decision.T.create` and `Decision.T.execute`
|
|
80
80
|
so business logic can read the caller identity or propagate correlation IDs.
|
|
81
81
|
*/
|
|
82
82
|
@schema
|