@reventlessdev/reventless-spec 3.0.0-alpha.129 → 3.0.0-alpha.130

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 CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.130 (2026-09-07)
7
+
8
+ * feat(dcb)!: a record inside an array is still a reference and a key ([bcb9b13](https://github.com/ReventlessDev/reventless-core/commit/bcb9b13958323d8319d7fcf25c055800e84150f3))
9
+ ### Features
10
+
11
+ * **core:** a command's lifecycle edge comes from its scenarios, not its claim ([1879d05](https://github.com/ReventlessDev/reventless-core/commit/1879d0570a981e7fae5bcd3808f8a36e898bd0fd))
12
+
13
+ ### BREAKING CHANGES
14
+
15
+ * an `int` field is now `Int` in the generated SDL and
16
+ `"integer"` in JSON Schema, where it was `Float`/`"number"`. A client
17
+ declaring `$retryCount: Float!` against one of these fields must move to
18
+ `Int!`.
19
+
20
+
21
+
6
22
  # 3.0.0-alpha.129 (2026-09-04)
7
23
 
8
24
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.129",
3
+ "version": "3.0.0-alpha.130",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -53,6 +53,7 @@ type Platform_AutomationSliceDef {
53
53
  type Platform_CommandDef {
54
54
  aggregateIdField: String
55
55
  allowedStates: [String!]
56
+ allowedStatesSource: String
56
57
  apiExposed: Boolean
57
58
  level: String!
58
59
  mutationField: String!
@@ -273,6 +273,56 @@ let isTaggedArray = (fieldSchema: S.t<unknown>) =>
273
273
  | _ => false
274
274
  }
275
275
 
276
+ /**
277
+ The properties of the record a field's value holds, when it holds records rather
278
+ than scalars — `record`, `option<record>`, `array<record>`, `option<array<record>>`
279
+ — with `true` for the array forms, which fan out into one contribution per element.
280
+
281
+ This is the single wrapper walk the nested-marker walks share, so a marker means
282
+ the same thing whichever of them finds it. It follows exactly the wrappers a
283
+ field's own value can wear and stops at the first record: a marker two records
284
+ deep is a shape no domain here has, and reaching it is a different and much more
285
+ expensive walk than this one.
286
+ */
287
+ let nestedRecordProperties = (fieldSchema: S.t<unknown>): option<(dict<S.t<unknown>>, bool)> => {
288
+ let unwrapped = fieldSchema->Semantic.unwrapOptional->Option.getOr(fieldSchema)
289
+ let (candidate, isList) = switch unwrapped {
290
+ | Array({additionalItems: Schema(item)}) => (
291
+ item->Semantic.unwrapOptional->Option.getOr(item),
292
+ true,
293
+ )
294
+ | other => (other, false)
295
+ }
296
+ switch candidate {
297
+ | Object({properties}) => Some((properties, isList))
298
+ | _ => None
299
+ }
300
+ }
301
+
302
+ /** Returns `true` if a record's own properties carry at least one DCB tag. */
303
+ let propertiesCarryTags = (properties: dict<S.t<unknown>>): bool =>
304
+ properties->Dict.toArray->Array.some(((_, s)) => isTagged(s) || isTaggedArray(s))
305
+
306
+ /**
307
+ Returns `true` if the schema is an array of records at least one of whose fields
308
+ is DCB-tagged — `array<lineItem>` where `lineItem` carries a `@ref` or a `*Id`.
309
+
310
+ Distinguished from [`hasNestedTags`] because an array is the case that *fans out*:
311
+ it decides the query mode, exactly as a tagged scalar array does.
312
+ */
313
+ let isTaggedRecordArray = (fieldSchema: S.t<unknown>): bool =>
314
+ switch nestedRecordProperties(fieldSchema) {
315
+ | Some((properties, true)) => propertiesCarryTags(properties)
316
+ | _ => false
317
+ }
318
+
319
+ /** Returns `true` if the schema holds a record — in an array or not — carrying tags. */
320
+ let hasNestedTags = (fieldSchema: S.t<unknown>): bool =>
321
+ switch nestedRecordProperties(fieldSchema) {
322
+ | Some((properties, _)) => propertiesCarryTags(properties)
323
+ | None => false
324
+ }
325
+
276
326
  /** Returns `true` if the schema was annotated with `DcbTag.partition`. */
277
327
  let isPartitionTag = (fieldSchema: S.t<unknown>) =>
278
328
  S.Metadata.get(fieldSchema, ~id=dcbPartitionTagId)->Option.isSome
@@ -318,25 +368,127 @@ let jsonValueToString = json =>
318
368
  | _ => json->JSON.stringify
319
369
  }
320
370
 
371
+ /**
372
+ Collapses repeated `(key, value)` pairs, keeping the first of each.
373
+
374
+ Two order lines for the same product carry the same tag twice. A flat field could
375
+ never produce that, which is why nothing downstream guards against it: a repeated
376
+ clause in a decision query and a repeated entry in a write index are both created
377
+ here, so both are collapsed here. The key is JSON-quoted before the value is
378
+ appended, so the boundary between the two is unambiguous.
379
+ */
380
+ let dedupeTags = (tags: array<tag>): array<tag> => {
381
+ let seen = Set.make()
382
+ tags->Array.filter(({key, value}) => {
383
+ let identity = key->JSON.Encode.string->JSON.stringify ++ value
384
+ if seen->Set.has(identity) {
385
+ false
386
+ } else {
387
+ seen->Set.add(identity)
388
+ true
389
+ }
390
+ })
391
+ }
392
+
393
+ /**
394
+ The tags one object's *own* properties carry: every scalar tagged field, and one
395
+ tag per element of every tagged scalar array.
396
+
397
+ Shared by the top-level expanded walk and by the nested-record descent below, so
398
+ a marker means the same thing at either depth.
399
+ */
400
+ let flatFieldTagsExpanded = (
401
+ fieldName: string,
402
+ fieldSchema: S.t<unknown>,
403
+ jsonDict: dict<JSON.t>,
404
+ ): array<tag> =>
405
+ if isTagged(fieldSchema) {
406
+ switch jsonDict->Dict.get(fieldName) {
407
+ | Some(jsonValue) => [
408
+ {key: resolveTagKey(fieldName, fieldSchema), value: jsonValue->jsonValueToString},
409
+ ]
410
+ | None => []
411
+ }
412
+ } else if isTaggedArray(fieldSchema) {
413
+ switch jsonDict->Dict.get(fieldName) {
414
+ | Some(JSON.Array(elements)) =>
415
+ let tagKey = resolveArrayTagKey(fieldName, fieldSchema)
416
+ elements->Array.map(element => {key: tagKey, value: element->jsonValueToString})
417
+ | _ => []
418
+ }
419
+ } else {
420
+ []
421
+ }
422
+
423
+ let flatTagsExpanded = (properties: dict<S.t<unknown>>, jsonDict: dict<JSON.t>): array<tag> =>
424
+ properties
425
+ ->Dict.toArray
426
+ ->Array.flatMap(((fieldName, fieldSchema)) =>
427
+ flatFieldTagsExpanded(fieldName, fieldSchema, jsonDict)
428
+ )
429
+
430
+ /**
431
+ The tags carried by the records a field holds — one contribution per element for
432
+ an array, one for a plain nested record.
433
+
434
+ **The key comes from the nested field's name, never the enclosing one.**
435
+ `lineItems[].productId` produces `productId`, which is what makes it the *same*
436
+ tag the catalog's own events write. A key derived from `lineItems` would name a
437
+ tag no producer has ever written, and the decision read would come back empty —
438
+ the identical symptom to extracting no tags at all, from a different cause. An
439
+ author who wants another key still writes `DcbTag.stringForKey` on the nested
440
+ field, which `resolveTagKey` honours here as it does at the top level.
441
+ */
442
+ let nestedRecordTags = (fieldSchema: S.t<unknown>, jsonValue: JSON.t): array<tag> =>
443
+ switch nestedRecordProperties(fieldSchema) {
444
+ | None => []
445
+ | Some((properties, isList)) =>
446
+ let ofObject = json =>
447
+ switch json->JSON.Decode.object {
448
+ | Some(elementDict) => flatTagsExpanded(properties, elementDict)
449
+ | None => []
450
+ }
451
+ if isList {
452
+ switch jsonValue {
453
+ | JSON.Array(elements) => elements->Array.flatMap(ofObject)
454
+ | _ => []
455
+ }
456
+ } else {
457
+ ofObject(jsonValue)
458
+ }
459
+ }
460
+
321
461
  /**
322
462
  Extracts tags from a flat JSON object given a map of field schemas.
323
463
  Only fields whose schema is tagged (via `DcbTag.string` / `DcbTag.int`) are extracted.
464
+
465
+ A field holding *records* is descended into, and this is the one case where this
466
+ walk and [`extractTagsFromPropertiesExpanded`] agree: they differ on a scalar
467
+ array (one stringified tag here, one tag per element there) because a stringified
468
+ array is at least a value, whereas a stringified line-item list is nothing any
469
+ producer could match. A reader must not generalise the difference.
324
470
  */
325
471
  let extractTagsFromProperties = (properties: dict<S.t<unknown>>, jsonDict: dict<JSON.t>) =>
326
472
  properties
327
473
  ->Dict.toArray
328
- ->Array.filterMap(((fieldName, fieldSchema)) =>
474
+ ->Array.flatMap(((fieldName, fieldSchema)) =>
329
475
  if isTagged(fieldSchema) {
330
- jsonDict
331
- ->Dict.get(fieldName)
332
- ->Option.map(jsonValue => {
333
- key: resolveTagKey(fieldName, fieldSchema),
334
- value: jsonValue->jsonValueToString,
335
- })
476
+ switch jsonDict->Dict.get(fieldName) {
477
+ | Some(jsonValue) => [
478
+ {key: resolveTagKey(fieldName, fieldSchema), value: jsonValue->jsonValueToString},
479
+ ]
480
+ | None => []
481
+ }
482
+ } else if hasNestedTags(fieldSchema) {
483
+ switch jsonDict->Dict.get(fieldName) {
484
+ | Some(jsonValue) => nestedRecordTags(fieldSchema, jsonValue)
485
+ | None => []
486
+ }
336
487
  } else {
337
- None
488
+ []
338
489
  }
339
490
  )
491
+ ->dedupeTags
340
492
 
341
493
  // Extract the discriminating TAG constructor name from a variant Object schema's
342
494
  // `properties`. sury (11) compiles a record-payload variant `Foo({...})` to an
@@ -517,6 +669,10 @@ For scalar tagged fields, behaves identically to `extractTagsFromProperties`.
517
669
  For array tagged fields, produces one tag per element. Tag keys honour an optional
518
670
  `DcbTag.stringForKey(~key=...)` override on the (inner) schema; otherwise the field
519
671
  name is used.
672
+
673
+ Fields holding records are descended into on the same terms as in
674
+ `extractTagsFromProperties` — the two walks agree there and differ only on scalar
675
+ arrays; see that function.
520
676
  */
521
677
  let extractTagsFromPropertiesExpanded = (
522
678
  properties: dict<S.t<unknown>>,
@@ -525,25 +681,18 @@ let extractTagsFromPropertiesExpanded = (
525
681
  properties
526
682
  ->Dict.toArray
527
683
  ->Array.flatMap(((fieldName, fieldSchema)) =>
528
- if isTagged(fieldSchema) {
684
+ if isTagged(fieldSchema) || isTaggedArray(fieldSchema) {
685
+ flatFieldTagsExpanded(fieldName, fieldSchema, jsonDict)
686
+ } else if hasNestedTags(fieldSchema) {
529
687
  switch jsonDict->Dict.get(fieldName) {
530
- | Some(jsonValue) => [
531
- {key: resolveTagKey(fieldName, fieldSchema), value: jsonValue->jsonValueToString},
532
- ]
688
+ | Some(jsonValue) => nestedRecordTags(fieldSchema, jsonValue)
533
689
  | None => []
534
690
  }
535
- } else if isTaggedArray(fieldSchema) {
536
- switch jsonDict->Dict.get(fieldName) {
537
- | Some(JSON.Array(elements)) => {
538
- let tagKey = resolveArrayTagKey(fieldName, fieldSchema)
539
- elements->Array.map(element => {key: tagKey, value: element->jsonValueToString})
540
- }
541
- | _ => []
542
- }
543
691
  } else {
544
692
  []
545
693
  }
546
694
  )
695
+ ->dedupeTags
547
696
 
548
697
  /**
549
698
  Extracts DCB tags from an event JSON value, expanding array values into per-element tags.
@@ -605,8 +754,9 @@ let extractTagsExpanded = (schema: S.t<'a>, value: 'a): array<tag> =>
605
754
  // --- Automatic query construction from command schema ---
606
755
 
607
756
  /**
608
- Returns `true` if any field in the schema is a tagged array
609
- (`array<@s.matches(DcbTag.string) string>`).
757
+ Returns `true` if any field in the schema fans out into several tags — a tagged
758
+ scalar array (`array<@s.matches(DcbTag.string) string>`) or an array of records
759
+ carrying tags (`array<lineItem>`).
610
760
  Used to automatically determine whether to build single-clause or multi-clause queries.
611
761
  */
612
762
  let hasTaggedArrayFields = (schema: S.t<'a>): bool =>
@@ -615,21 +765,30 @@ let hasTaggedArrayFields = (schema: S.t<'a>): bool =>
615
765
  anyOf->Array.some(variantSchema =>
616
766
  switch variantSchema {
617
767
  | Object({properties}) =>
618
- properties->Dict.toArray->Array.some(((_, fieldSchema)) => isTaggedArray(fieldSchema))
768
+ properties
769
+ ->Dict.toArray
770
+ ->Array.some(((_, fieldSchema)) =>
771
+ isTaggedArray(fieldSchema) || isTaggedRecordArray(fieldSchema)
772
+ )
619
773
  | _ => false
620
774
  }
621
775
  )
622
776
  | Object({properties}) =>
623
- properties->Dict.toArray->Array.some(((_, fieldSchema)) => isTaggedArray(fieldSchema))
777
+ properties
778
+ ->Dict.toArray
779
+ ->Array.some(((_, fieldSchema)) =>
780
+ isTaggedArray(fieldSchema) || isTaggedRecordArray(fieldSchema)
781
+ )
624
782
  | _ => false
625
783
  }
626
784
 
627
785
  /**
628
786
  Collects the produced tag keys of one event-schema variant: the resolved tag key
629
- of every scalar tagged field plus the resolved (override-aware) tag key of every
630
- tagged-array field.
787
+ of every scalar tagged field, the resolved (override-aware) tag key of every
788
+ tagged-array field, and the keys of the tagged fields of any records the variant
789
+ holds — under the nested field's own name, as the extraction resolves them.
631
790
  */
632
- let tagKeysOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
791
+ let flatTagKeysOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
633
792
  properties
634
793
  ->Dict.toArray
635
794
  ->Array.filterMap(((fieldName, fieldSchema)) =>
@@ -642,6 +801,22 @@ let tagKeysOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
642
801
  }
643
802
  )
644
803
 
804
+ let tagKeysOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
805
+ properties
806
+ ->Dict.toArray
807
+ ->Array.flatMap(((fieldName, fieldSchema)) =>
808
+ if isTagged(fieldSchema) {
809
+ [resolveTagKey(fieldName, fieldSchema)]
810
+ } else if isTaggedArray(fieldSchema) {
811
+ [resolveArrayTagKey(fieldName, fieldSchema)]
812
+ } else {
813
+ switch nestedRecordProperties(fieldSchema) {
814
+ | Some((nested, _)) => flatTagKeysOfProperties(nested)
815
+ | None => []
816
+ }
817
+ }
818
+ )
819
+
645
820
  /**
646
821
  Maps each event-type (variant constructor name) to the set of DCB tag keys that
647
822
  type can carry, read from a *produced* event-log schema.
@@ -882,21 +1057,43 @@ let extractCrossPartitionTagKeys = (schema: S.t<'event>): array<string> => {
882
1057
  Collects the `*Id` / `*Ids`-shaped fields of one object-variant's properties as
883
1058
  `DcbScopeInference.idField`s — by **name**, independent of any DCB tag flag. This
884
1059
  is the un-annotated structural view the scope inference consumes.
1060
+
1061
+ A field holding records contributes the `*Id`-shaped fields of those records
1062
+ under their **own** names, and a field reached through an array is reported as a
1063
+ list whichever container it came from: the inference reasons about keys, and a
1064
+ key reached per line fans out exactly as a key reached per array element does.
1065
+ Reporting it as single-valued would derive the wrong partition for the slice.
1066
+
1067
+ That `DcbScopeInference.idField` needs no change to carry this is the evidence
1068
+ the boundary was drawn in the right place — the descent is a fact about schemas,
1069
+ which is this module's half of the split.
885
1070
  */
886
- let idFieldsOfProperties = (properties: dict<S.t<unknown>>): array<DcbScopeInference.idField> =>
1071
+ let idFieldsOfProperties = (properties: dict<S.t<unknown>>): array<DcbScopeInference.idField> => {
1072
+ let isIdName = (name: string) => name->String.endsWith("Ids") || name->String.endsWith("Id")
887
1073
  properties
888
1074
  ->Dict.toArray
889
- ->Array.filterMap(((name, fieldSchema)) =>
890
- if name->String.endsWith("Ids") || name->String.endsWith("Id") {
1075
+ ->Array.flatMap(((name, fieldSchema)) =>
1076
+ if isIdName(name) {
891
1077
  let isList = switch fieldSchema {
892
1078
  | Array(_) => true
893
1079
  | _ => false
894
1080
  }
895
- Some({DcbScopeInference.name, isList})
1081
+ [{DcbScopeInference.name, isList}]
896
1082
  } else {
897
- None
1083
+ switch nestedRecordProperties(fieldSchema) {
1084
+ | Some((nested, nestedIsList)) =>
1085
+ nested
1086
+ ->Dict.toArray
1087
+ ->Array.filterMap(((nestedName, _)) =>
1088
+ isIdName(nestedName)
1089
+ ? Some({DcbScopeInference.name: nestedName, isList: nestedIsList})
1090
+ : None
1091
+ )
1092
+ | None => []
1093
+ }
898
1094
  }
899
1095
  )
1096
+ }
900
1097
 
901
1098
  /**
902
1099
  Extracts the `DcbScopeInference.eventShape`s (variant name + `*Id` fields) from a
@@ -8,6 +8,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
8
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
9
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
10
10
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
11
+ import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
11
12
  import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
12
13
  import * as DcbScopeInference$Reventless from "./DcbScopeInference.res.mjs";
13
14
 
@@ -86,6 +87,62 @@ function isTaggedArray(fieldSchema) {
86
87
  }
87
88
  }
88
89
 
90
+ function nestedRecordProperties(fieldSchema) {
91
+ let unwrapped = Stdlib_Option.getOr(Semantic$Reventless.unwrapOptional(fieldSchema), fieldSchema);
92
+ let match;
93
+ if (unwrapped.type === "array") {
94
+ let item = unwrapped.additionalItems;
95
+ match = item === "strip" || item === "strict" ? [
96
+ unwrapped,
97
+ false
98
+ ] : [
99
+ Stdlib_Option.getOr(Semantic$Reventless.unwrapOptional(item), item),
100
+ true
101
+ ];
102
+ } else {
103
+ match = [
104
+ unwrapped,
105
+ false
106
+ ];
107
+ }
108
+ let candidate = match[0];
109
+ if (candidate.type === "object") {
110
+ return [
111
+ candidate.properties,
112
+ match[1]
113
+ ];
114
+ }
115
+ }
116
+
117
+ function propertiesCarryTags(properties) {
118
+ return Object.entries(properties).some(param => {
119
+ let s = param[1];
120
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(s, dcbTagId))) {
121
+ return true;
122
+ } else {
123
+ return isTaggedArray(s);
124
+ }
125
+ });
126
+ }
127
+
128
+ function isTaggedRecordArray(fieldSchema) {
129
+ let match = nestedRecordProperties(fieldSchema);
130
+ if (match !== undefined && match[1]) {
131
+ return propertiesCarryTags(match[0]);
132
+ } else {
133
+ return false;
134
+ }
135
+ }
136
+
137
+ function hasNestedTags(fieldSchema) {
138
+ let match = nestedRecordProperties(fieldSchema);
139
+ if (match !== undefined) {
140
+ return propertiesCarryTags(match[0]);
141
+ } else {
142
+ return false;
143
+ }
144
+ }
145
+
89
146
  function isPartitionTag(fieldSchema) {
90
147
  return Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbPartitionTagId));
91
148
  }
@@ -139,17 +196,102 @@ function jsonValueToString(json) {
139
196
  }
140
197
  }
141
198
 
199
+ function dedupeTags(tags) {
200
+ let seen = new Set();
201
+ return tags.filter(param => {
202
+ let identity = JSON.stringify(param.key) + param.value;
203
+ if (seen.has(identity)) {
204
+ return false;
205
+ } else {
206
+ seen.add(identity);
207
+ return true;
208
+ }
209
+ });
210
+ }
211
+
212
+ function flatFieldTagsExpanded(fieldName, fieldSchema, jsonDict) {
213
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbTagId))) {
214
+ let jsonValue = jsonDict[fieldName];
215
+ if (jsonValue !== undefined) {
216
+ return [{
217
+ key: resolveTagKey(fieldName, fieldSchema),
218
+ value: jsonValueToString(jsonValue)
219
+ }];
220
+ } else {
221
+ return [];
222
+ }
223
+ }
224
+ if (!isTaggedArray(fieldSchema)) {
225
+ return [];
226
+ }
227
+ let match = jsonDict[fieldName];
228
+ if (match === undefined) {
229
+ return [];
230
+ }
231
+ if (!Array.isArray(match)) {
232
+ return [];
233
+ }
234
+ let tagKey = resolveArrayTagKey(fieldName, fieldSchema);
235
+ return match.map(element => ({
236
+ key: tagKey,
237
+ value: jsonValueToString(element)
238
+ }));
239
+ }
240
+
241
+ function flatTagsExpanded(properties, jsonDict) {
242
+ return Object.entries(properties).flatMap(param => flatFieldTagsExpanded(param[0], param[1], jsonDict));
243
+ }
244
+
245
+ function nestedRecordTags(fieldSchema, jsonValue) {
246
+ let match = nestedRecordProperties(fieldSchema);
247
+ if (match === undefined) {
248
+ return [];
249
+ }
250
+ let properties = match[0];
251
+ let ofObject = json => {
252
+ let elementDict = Stdlib_JSON.Decode.object(json);
253
+ if (elementDict !== undefined) {
254
+ return flatTagsExpanded(properties, elementDict);
255
+ } else {
256
+ return [];
257
+ }
258
+ };
259
+ if (match[1]) {
260
+ if (Array.isArray(jsonValue)) {
261
+ return jsonValue.flatMap(ofObject);
262
+ } else {
263
+ return [];
264
+ }
265
+ } else {
266
+ return ofObject(jsonValue);
267
+ }
268
+ }
269
+
142
270
  function extractTagsFromProperties(properties, jsonDict) {
143
- return Stdlib_Array.filterMap(Object.entries(properties), param => {
271
+ return dedupeTags(Object.entries(properties).flatMap(param => {
144
272
  let fieldSchema = param[1];
145
273
  let fieldName = param[0];
146
274
  if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbTagId))) {
147
- return Stdlib_Option.map(jsonDict[fieldName], jsonValue => ({
148
- key: resolveTagKey(fieldName, fieldSchema),
149
- value: jsonValueToString(jsonValue)
150
- }));
275
+ let jsonValue = jsonDict[fieldName];
276
+ if (jsonValue !== undefined) {
277
+ return [{
278
+ key: resolveTagKey(fieldName, fieldSchema),
279
+ value: jsonValueToString(jsonValue)
280
+ }];
281
+ } else {
282
+ return [];
283
+ }
151
284
  }
152
- });
285
+ if (!hasNestedTags(fieldSchema)) {
286
+ return [];
287
+ }
288
+ let jsonValue$1 = jsonDict[fieldName];
289
+ if (jsonValue$1 !== undefined) {
290
+ return nestedRecordTags(fieldSchema, jsonValue$1);
291
+ } else {
292
+ return [];
293
+ }
294
+ }));
153
295
  }
154
296
 
155
297
  function variantTagName(properties) {
@@ -275,36 +417,22 @@ function isVariantPayloadBearing(schema, name) {
275
417
  }
276
418
 
277
419
  function extractTagsFromPropertiesExpanded(properties, jsonDict) {
278
- return Object.entries(properties).flatMap(param => {
420
+ return dedupeTags(Object.entries(properties).flatMap(param => {
279
421
  let fieldSchema = param[1];
280
422
  let fieldName = param[0];
281
- if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbTagId))) {
282
- let jsonValue = jsonDict[fieldName];
283
- if (jsonValue !== undefined) {
284
- return [{
285
- key: resolveTagKey(fieldName, fieldSchema),
286
- value: jsonValueToString(jsonValue)
287
- }];
288
- } else {
289
- return [];
290
- }
291
- }
292
- if (!isTaggedArray(fieldSchema)) {
293
- return [];
423
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbTagId)) || isTaggedArray(fieldSchema)) {
424
+ return flatFieldTagsExpanded(fieldName, fieldSchema, jsonDict);
294
425
  }
295
- let match = jsonDict[fieldName];
296
- if (match === undefined) {
426
+ if (!hasNestedTags(fieldSchema)) {
297
427
  return [];
298
428
  }
299
- if (!Array.isArray(match)) {
429
+ let jsonValue = jsonDict[fieldName];
430
+ if (jsonValue !== undefined) {
431
+ return nestedRecordTags(fieldSchema, jsonValue);
432
+ } else {
300
433
  return [];
301
434
  }
302
- let tagKey = resolveArrayTagKey(fieldName, fieldSchema);
303
- return match.map(element => ({
304
- key: tagKey,
305
- value: jsonValueToString(element)
306
- }));
307
- });
435
+ }));
308
436
  }
309
437
 
310
438
  function extractTagsFromJsonExpanded(schema, json) {
@@ -352,11 +480,25 @@ function extractTagsExpanded(schema, value) {
352
480
  function hasTaggedArrayFields(schema) {
353
481
  switch (schema.type) {
354
482
  case "object" :
355
- return Object.entries(schema.properties).some(param => isTaggedArray(param[1]));
483
+ return Object.entries(schema.properties).some(param => {
484
+ let fieldSchema = param[1];
485
+ if (isTaggedArray(fieldSchema)) {
486
+ return true;
487
+ } else {
488
+ return isTaggedRecordArray(fieldSchema);
489
+ }
490
+ });
356
491
  case "anyOf" :
357
492
  return schema.anyOf.some(variantSchema => {
358
493
  if (variantSchema.type === "object") {
359
- return Object.entries(variantSchema.properties).some(param => isTaggedArray(param[1]));
494
+ return Object.entries(variantSchema.properties).some(param => {
495
+ let fieldSchema = param[1];
496
+ if (isTaggedArray(fieldSchema)) {
497
+ return true;
498
+ } else {
499
+ return isTaggedRecordArray(fieldSchema);
500
+ }
501
+ });
360
502
  } else {
361
503
  return false;
362
504
  }
@@ -366,7 +508,7 @@ function hasTaggedArrayFields(schema) {
366
508
  }
367
509
  }
368
510
 
369
- function tagKeysOfProperties(properties) {
511
+ function flatTagKeysOfProperties(properties) {
370
512
  return Stdlib_Array.filterMap(Object.entries(properties), param => {
371
513
  let fieldSchema = param[1];
372
514
  let fieldName = param[0];
@@ -380,6 +522,25 @@ function tagKeysOfProperties(properties) {
380
522
  });
381
523
  }
382
524
 
525
+ function tagKeysOfProperties(properties) {
526
+ return Object.entries(properties).flatMap(param => {
527
+ let fieldSchema = param[1];
528
+ let fieldName = param[0];
529
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbTagId))) {
530
+ return [resolveTagKey(fieldName, fieldSchema)];
531
+ }
532
+ if (isTaggedArray(fieldSchema)) {
533
+ return [resolveArrayTagKey(fieldName, fieldSchema)];
534
+ }
535
+ let match = nestedRecordProperties(fieldSchema);
536
+ if (match !== undefined) {
537
+ return flatTagKeysOfProperties(match[0]);
538
+ } else {
539
+ return [];
540
+ }
541
+ });
542
+ }
543
+
383
544
  function extractTagKeysByEventType(schema) {
384
545
  let result = {};
385
546
  let addVariant = variantSchema => {
@@ -530,17 +691,38 @@ function extractCrossPartitionTagKeys(schema) {
530
691
  }
531
692
 
532
693
  function idFieldsOfProperties(properties) {
533
- return Stdlib_Array.filterMap(Object.entries(properties), param => {
694
+ let isIdName = name => {
695
+ if (name.endsWith("Ids")) {
696
+ return true;
697
+ } else {
698
+ return name.endsWith("Id");
699
+ }
700
+ };
701
+ return Object.entries(properties).flatMap(param => {
702
+ let fieldSchema = param[1];
534
703
  let name = param[0];
535
- if (!(name.endsWith("Ids") || name.endsWith("Id"))) {
536
- return;
704
+ if (isIdName(name)) {
705
+ let isList;
706
+ isList = fieldSchema.type === "array";
707
+ return [{
708
+ name: name,
709
+ isList: isList
710
+ }];
537
711
  }
538
- let isList;
539
- isList = param[1].type === "array";
540
- return {
541
- name: name,
542
- isList: isList
543
- };
712
+ let match = nestedRecordProperties(fieldSchema);
713
+ if (match === undefined) {
714
+ return [];
715
+ }
716
+ let nestedIsList = match[1];
717
+ return Stdlib_Array.filterMap(Object.entries(match[0]), param => {
718
+ let nestedName = param[0];
719
+ if (isIdName(nestedName)) {
720
+ return {
721
+ name: nestedName,
722
+ isList: nestedIsList
723
+ };
724
+ }
725
+ });
544
726
  });
545
727
  }
546
728
 
@@ -862,12 +1044,20 @@ export {
862
1044
  derivedPartitionTagSchema,
863
1045
  isTagged,
864
1046
  isTaggedArray,
1047
+ nestedRecordProperties,
1048
+ propertiesCarryTags,
1049
+ isTaggedRecordArray,
1050
+ hasNestedTags,
865
1051
  isPartitionTag,
866
1052
  isCrossPartitionTag,
867
1053
  isCrossPartitionTaggedArray,
868
1054
  resolveTagKey,
869
1055
  resolveArrayTagKey,
870
1056
  jsonValueToString,
1057
+ dedupeTags,
1058
+ flatFieldTagsExpanded,
1059
+ flatTagsExpanded,
1060
+ nestedRecordTags,
871
1061
  extractTagsFromProperties,
872
1062
  variantTagName,
873
1063
  extractTagsFromJson,
@@ -879,6 +1069,7 @@ export {
879
1069
  extractTagsFromJsonExpanded,
880
1070
  extractTagsExpanded,
881
1071
  hasTaggedArrayFields,
1072
+ flatTagKeysOfProperties,
882
1073
  tagKeysOfProperties,
883
1074
  extractTagKeysByEventType,
884
1075
  mergeTagKeysByEventType,
@@ -122,6 +122,32 @@ let uiFragmentManifestOptionSchema = S.option(uiFragmentManifestSchema)
122
122
  @schema
123
123
  type commandLevel = Collection | Instance
124
124
 
125
+ /** One command's lifecycle edge as the component's own scenarios describe it:
126
+ where a scenario shows the command taking effect, where those scenarios land,
127
+ and whether it brings a row into existence or acts on one.
128
+
129
+ Harvested from the GWT corpus by `check:lifecycle` and committed beside the
130
+ plugin as `src/LifecycleModel.res`, so structure assembly reads a value rather
131
+ than the test tree — which is not published with a plugin package.
132
+
133
+ No `@schema`: nothing serialises this. It is read once, while the structure is
134
+ assembled, and what leaves is the `commandDef` fields it resolved. */
135
+ type derivedEdge = {
136
+ /** The writable's `Spec.name`, which is what the structure knows it by. */
137
+ component: string,
138
+ command: string,
139
+ /** Absent where the corpus could not label a history — the honest answer when
140
+ the linked views declare no lifecycle field, and the reason the name-stem
141
+ guess is still there to fall back to. */
142
+ level?: commandLevel,
143
+ /** States a scenario shows the command taking effect from. Empty means the
144
+ corpus said nothing, NOT that the command belongs nowhere. */
145
+ allowedStates: array<string>,
146
+ /** States those scenarios land in. More than one is an edge a single
147
+ `targetState` cannot carry, so it is left to the declaration. */
148
+ targets: array<string>,
149
+ }
150
+
125
151
  @schema
126
152
  type fieldReference = {
127
153
  fieldName: string,
@@ -137,12 +163,29 @@ type commandDef = {
137
163
  aggregateIdField: option<string>,
138
164
  mutationField: string,
139
165
  references: array<fieldReference>,
140
- /** The declared *from* set — lifecycle states this command is meaningful in.
141
- `None` means always available; `Some([])` means never show. */
166
+ /** The *from* set — lifecycle states this command is meaningful in. `None`
167
+ means always available; `Some([])` means never show. */
142
168
  allowedStates: option<array<string>>,
143
- /** The declared *to* state this command's handler writes. `None` with a
144
- from-set present means the command does not move the row. */
169
+ /** The *to* state this command's handler writes. `None` with a from-set present
170
+ means the command does not move the row. */
145
171
  targetState: option<string>,
172
+ /** Where the from-set came from, ranking an inherited edge against an authored
173
+ one the way `queryableDef.labelFieldSource` does for its field:
174
+
175
+ - `"derived"` — the component's own scenarios.
176
+ - `"declared"` — its `commandTransition` switch.
177
+ - `"unrestricted"` — the switch declares the command legal in *every*
178
+ state, so there is no from-set to publish and `allowedStates` is absent.
179
+
180
+ Absent means the command names no states to come from and nothing said that
181
+ was deliberate — a spec that wrote no switch, or one whose command creates
182
+ the row. So a consumer drawing a state machine can tell an edge left
183
+ unconstrained on purpose from one that simply has no from-set.
184
+
185
+ It speaks for the from-set only. The two halves of an edge are reported
186
+ separately because they fail separately: a corpus routinely shows a command
187
+ taking effect without ever showing where it lands. */
188
+ allowedStatesSource?: string,
146
189
  /** Whether the variant is exposed in the generated API (non-`@noApi`). */
147
190
  apiExposed: option<bool>,
148
191
  /** Access keys — any one of them — a caller needs to be *offered* this command.
@@ -99,6 +99,7 @@ let commandDefSchema = Sury.$schema(s => ({
99
99
  references: s.m(Sury.array(fieldReferenceSchema)),
100
100
  allowedStates: s.m(Sury.$option(Sury.array(Sury.string))),
101
101
  targetState: s.m(Sury.$option(Sury.string)),
102
+ allowedStatesSource: s.m(Sury.$option(Sury.string)),
102
103
  apiExposed: s.m(Sury.$option(Sury.bool)),
103
104
  requiredAccess: s.m(Sury.$option(Sury.array(Sury.string))),
104
105
  ownerField: s.m(Sury.$option(Sury.string))
@@ -62,6 +62,11 @@ Only wrappers around the field's own value are followed — the optional union a
62
62
  the array element, to any depth. Object properties are not: a reference declared
63
63
  on a nested record's field belongs to that field, and attributing it to the
64
64
  enclosing one would name the wrong field.
65
+
66
+ That is still the right answer to the question this asks. A caller collecting
67
+ *every* reference a command or event declares — including the ones on the fields
68
+ of records it holds — wants [`collectFieldTargets`], which names each one by its
69
+ own path.
65
70
  */
66
71
  let rec getFieldTarget = (schema: S.t<unknown>): option<target> =>
67
72
  switch getTarget(schema) {
@@ -75,6 +80,49 @@ let rec getFieldTarget = (schema: S.t<unknown>): option<target> =>
75
80
  }
76
81
  }
77
82
 
83
+ /**
84
+ Every reference a field declares, each paired with the path that names it.
85
+
86
+ A reference on the field's own value keeps the bare field name (`customerId`), so
87
+ nothing that reads today's paths has to learn the new form to go on working. One
88
+ declared on a field of a record the field holds is named by its path:
89
+ `lineItems[].productId` — `[]` for "each element of", `.` for "property of", and
90
+ no index, because a marker lives on the element *schema* and so applies to every
91
+ element or to none.
92
+
93
+ ⚠️ A path is a **widened** contract, not a compatible one, for a consumer that
94
+ uses the name as a dictionary key into a value's properties: such a consumer
95
+ simply misses a nested reference and falls back to whatever it does for an
96
+ unreferenced field. That degradation is the correct one, but it is a degradation.
97
+
98
+ Only one record deep, matching [`DcbTag.nestedRecordProperties`], which is the
99
+ walk shared with the tag extraction so a marker is found in the same places by
100
+ both.
101
+ */
102
+ let collectFieldTargets = (
103
+ fieldName: string,
104
+ schema: S.t<unknown>,
105
+ ): array<(string, target)> =>
106
+ switch getFieldTarget(schema) {
107
+ | Some(target) => [(fieldName, target)]
108
+ | None =>
109
+ switch DcbTag.nestedRecordProperties(schema) {
110
+ | None => []
111
+ | Some((properties, isList)) =>
112
+ let prefix = isList ? fieldName ++ "[]" : fieldName
113
+ properties
114
+ ->Dict.toArray
115
+ ->Array.filterMap(((propName, propSchema)) =>
116
+ propName === "TAG"
117
+ ? None
118
+ : getFieldTarget(propSchema)->Option.map(target => (
119
+ `${prefix}.${propName}`,
120
+ target,
121
+ ))
122
+ )
123
+ }
124
+ }
125
+
78
126
  /**
79
127
  Like `to_` but does not imply DCB tag semantics.
80
128
  Use with `@ref("Entity") @noDcbTag` when the field references another entity
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Sury from "sury";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as DcbTag$Reventless from "./DcbTag.res.mjs";
6
7
  import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
@@ -53,6 +54,32 @@ function getFieldTarget(_schema) {
53
54
  };
54
55
  }
55
56
 
57
+ function collectFieldTargets(fieldName, schema) {
58
+ let target = getFieldTarget(schema);
59
+ if (target !== undefined) {
60
+ return [[
61
+ fieldName,
62
+ target
63
+ ]];
64
+ }
65
+ let match = DcbTag$Reventless.nestedRecordProperties(schema);
66
+ if (match === undefined) {
67
+ return [];
68
+ }
69
+ let prefix = match[1] ? fieldName + "[]" : fieldName;
70
+ return Stdlib_Array.filterMap(Object.entries(match[0]), param => {
71
+ let propName = param[0];
72
+ if (propName === "TAG") {
73
+ return;
74
+ } else {
75
+ return Stdlib_Option.map(getFieldTarget(param[1]), target => [
76
+ prefix + `.` + propName,
77
+ target
78
+ ]);
79
+ }
80
+ });
81
+ }
82
+
56
83
  function toWithoutDcbTag(plugin, entity) {
57
84
  return Semantic$Reventless.mark(Sury.string, Semantic$Reventless.Id.reference, {
58
85
  TAG: "ReferenceTo",
@@ -67,6 +94,7 @@ export {
67
94
  to_,
68
95
  getTarget,
69
96
  getFieldTarget,
97
+ collectFieldTargets,
70
98
  toWithoutDcbTag,
71
99
  }
72
100
  /* sury Not a pure module */
@@ -350,6 +350,7 @@ let renderPluginStructureCall = (
350
350
  ~extensions: array<string>,
351
351
  ~extensionPoints: array<Pairing.extensionPointDef>,
352
352
  ~componentChapters: array<(string, string)>,
353
+ ~hasLifecycleModel: bool,
353
354
  ): option<array<string>> => {
354
355
  // The structure call carries the EP *mapping* files (one per Delegate
355
356
  // connection), not the wrapped ExtensionPoint module — Plugin_Structure reads
@@ -428,6 +429,15 @@ let renderPluginStructureCall = (
428
429
  )
429
430
  ls->Array.push(" ~componentChapters=Dict.fromArray([" ++ entries->Array.join(", ") ++ "]),")
430
431
  }
432
+ // What the plugin's own scenarios say about each command's lifecycle edge.
433
+ // Written by `check:lifecycle:update`, not here: the sidecars it harvests are
434
+ // produced by the build this generator runs *before*, so a generator-written
435
+ // model would always be one build stale and a cold clone would have none at
436
+ // all. Referenced only when the file is on disk, so a plugin that has never
437
+ // been harvested keeps a byte-identical generated Plugin.res.
438
+ if hasLifecycleModel {
439
+ ls->Array.push(" ~lifecycleModel=LifecycleModel.model,")
440
+ }
431
441
  ls->Array.push(" )")
432
442
  Some(ls)
433
443
  }
@@ -595,6 +605,7 @@ let renderComposition = (
595
605
  ~config: Config.config,
596
606
  ~resolved: Pairing.resolved,
597
607
  ~componentChapters: array<(string, string)>,
608
+ ~hasLifecycleModel: bool,
598
609
  ): string => {
599
610
  let lines: array<string> = []
600
611
 
@@ -740,6 +751,7 @@ let renderComposition = (
740
751
  ~extensions=resolved.extensions,
741
752
  ~extensionPoints=resolved.extensionPoints,
742
753
  ~componentChapters,
754
+ ~hasLifecycleModel,
743
755
  )
744
756
  let hasPluginStructure = pluginStructureLines->Option.isSome
745
757
  switch pluginStructureLines {
@@ -833,6 +845,7 @@ let render = (
833
845
  ~config: Config.config,
834
846
  ~resolved: Pairing.resolved,
835
847
  ~discovered: array<Discovery.discoveredFile>,
848
+ ~hasLifecycleModel: bool=false,
836
849
  ): string => {
837
850
  validateUniqueSpecStems(~discovered)
838
851
  validateSliceTargets(~resolved)
@@ -858,6 +871,6 @@ let render = (
858
871
  )
859
872
  switch config.variant {
860
873
  | Aws({compositionNamespace}) => renderAwsWrapper(~compositionNamespace)
861
- | Composition => renderComposition(~config, ~resolved, ~componentChapters)
874
+ | Composition => renderComposition(~config, ~resolved, ~componentChapters, ~hasLifecycleModel)
862
875
  }
863
876
  }
@@ -239,7 +239,7 @@ function renderTaskMakeParam(tasks) {
239
239
  return " ~tasks=[" + entries.join(", ") + "],";
240
240
  }
241
241
 
242
- function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices, stateViewSlicesStream, stateChangeSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, extensions, extensionPoints, componentChapters) {
242
+ function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices, stateViewSlicesStream, stateChangeSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, extensions, extensionPoints, componentChapters, hasLifecycleModel) {
243
243
  let epMappingStems = extensionPoints.flatMap(param => param.mappings);
244
244
  let hasComponents = aggregates.length !== 0 || readModels.length !== 0 || stateViewSlices.length !== 0 || stateViewSlicesStream.length !== 0 || stateChangeSlices.length !== 0 || automationSlices.length !== 0 || outboundTranslationSlices.length !== 0 || inboundTranslationSlices.length !== 0 || extensions.length !== 0 || epMappingStems.length !== 0;
245
245
  if (!hasComponents) {
@@ -295,6 +295,9 @@ function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices
295
295
  let entries$8 = componentChapters.map(param => "(\"" + param[0] + "\", \"" + param[1] + "\")");
296
296
  ls.push(" ~componentChapters=Dict.fromArray([" + entries$8.join(", ") + "]),");
297
297
  }
298
+ if (hasLifecycleModel) {
299
+ ls.push(" ~lifecycleModel=LifecycleModel.model,");
300
+ }
298
301
  ls.push(" )");
299
302
  return ls;
300
303
  }
@@ -394,7 +397,7 @@ function renderAwsWrapper(compositionNamespace) {
394
397
  ].join("\n");
395
398
  }
396
399
 
397
- function renderComposition(config, resolved, componentChapters) {
400
+ function renderComposition(config, resolved, componentChapters, hasLifecycleModel) {
398
401
  let lines = [];
399
402
  lines.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.");
400
403
  lines.push("");
@@ -459,7 +462,7 @@ function renderComposition(config, resolved, componentChapters) {
459
462
  lines.push(" // Extensions");
460
463
  push(renderExtensions(resolved.extensions));
461
464
  }
462
- let pluginStructureLines = renderPluginStructureCall(config.name, resolved.aggregates, resolved.readModels, resolved.stateViewSlices, resolved.stateViewSlicesStream, resolved.stateChangeSlices, resolved.automationSlices, resolved.outboundTranslationSlices, resolved.inboundTranslationSlices, resolved.extensions, resolved.extensionPoints, componentChapters);
465
+ let pluginStructureLines = renderPluginStructureCall(config.name, resolved.aggregates, resolved.readModels, resolved.stateViewSlices, resolved.stateViewSlicesStream, resolved.stateChangeSlices, resolved.automationSlices, resolved.outboundTranslationSlices, resolved.inboundTranslationSlices, resolved.extensions, resolved.extensionPoints, componentChapters, hasLifecycleModel);
463
466
  let hasPluginStructure = Stdlib_Option.isSome(pluginStructureLines);
464
467
  if (pluginStructureLines !== undefined) {
465
468
  pluginStructureLines.forEach(l => {
@@ -517,7 +520,8 @@ function renderComposition(config, resolved, componentChapters) {
517
520
  return lines.join("\n");
518
521
  }
519
522
 
520
- function render(config, resolved, discovered) {
523
+ function render(config, resolved, discovered, hasLifecycleModelOpt) {
524
+ let hasLifecycleModel = hasLifecycleModelOpt !== undefined ? hasLifecycleModelOpt : false;
521
525
  validateUniqueSpecStems(discovered);
522
526
  validateSliceTargets(resolved);
523
527
  let componentStems = [
@@ -533,7 +537,7 @@ function render(config, resolved, discovered) {
533
537
  let componentChapters = Discovery$Reventless.chaptersByStem(discovered).filter(param => componentStems.includes(param[0]));
534
538
  let match = config.variant;
535
539
  if (typeof match !== "object") {
536
- return renderComposition(config, resolved, componentChapters);
540
+ return renderComposition(config, resolved, componentChapters, hasLifecycleModel);
537
541
  } else {
538
542
  return renderAwsWrapper(match.compositionNamespace);
539
543
  }
@@ -34,7 +34,10 @@ let () = {
34
34
  let config = {...Config.read(~srcDir), variant}
35
35
  let discovered = Discovery.scan(~srcDir, ~exclude=config.exclude)
36
36
  let resolved = Pairing.resolve(discovered, ~srcDir)
37
- let source = Codegen.render(~config, ~resolved, ~discovered)
37
+ // Sits at the src root rather than in a kind folder, so `Discovery` never
38
+ // sees it and it is not a component. Its presence is the whole question.
39
+ let hasLifecycleModel = NodePath.join([srcDir, "LifecycleModel.res"])->NodeFs.existsSync
40
+ let source = Codegen.render(~config, ~resolved, ~discovered, ~hasLifecycleModel)
38
41
 
39
42
  let outputDir = switch variant {
40
43
  | Config.Composition => srcDir
@@ -56,7 +56,8 @@ if (srcDirArg === "") {
56
56
  };
57
57
  let discovered = Discovery$Reventless.scan(srcDir, config_exclude);
58
58
  let resolved = Pairing$Reventless.resolve(discovered, srcDir);
59
- let source = Codegen$Reventless.render(config, resolved, discovered);
59
+ let hasLifecycleModel = Nodefs.existsSync(Nodepath.join(srcDir, "LifecycleModel.res"));
60
+ let source = Codegen$Reventless.render(config, resolved, discovered, hasLifecycleModel);
60
61
  let outputDir;
61
62
  outputDir = typeof variant !== "object" ? srcDir : Nodepath.join(process.cwd(), "src");
62
63
  let pluginPath = Nodepath.join(outputDir, "Plugin.res");
@@ -36,10 +36,23 @@
36
36
  // plugin structure is assembled, and what leaves is the pair of names the
37
37
  // structure already carried.
38
38
  type t<'state> =
39
- /** No edge declared: legal in every state, moves the row nowhere. The honest
40
- answer for a report a slice publishes, which must not be refused because
41
- the row moved on while the report was in flight. */
39
+ /** Legal in every state, and moves the row nowhere. The honest answer for a
40
+ report a slice publishes, which must not be refused because the row moved
41
+ on while the report was in flight. A claim, not an omission — see
42
+ `Undeclared`, which is the omission. */
42
43
  | Unrestricted
44
+ /** The spec wrote no switch, and the ppx injected this. Never write it: say
45
+ `Unrestricted` if you mean the command is legal everywhere.
46
+
47
+ It reads exactly like `Unrestricted` — no from-set, no target — everywhere
48
+ but one place, and that place is why it exists. The harvested lifecycle
49
+ model may answer for silence; it may not narrow a claim. A corpus only
50
+ covers the states somebody wrote a scenario for, so letting it answer for
51
+ `Unrestricted` would shrink "legal in every state" down to an accident of
52
+ coverage, and the command would quietly stop being offered on the rows
53
+ nobody tested. With the two spelled apart, that shrinkage cannot happen and
54
+ a scenario that genuinely refutes the claim is a contradiction instead. */
55
+ | Undeclared
43
56
  /** Brings the row into existence, so there is no state it could come from.
44
57
  Distinct from `Unrestricted`, which draws no edge at all. */
45
58
  | Creates('state)
@@ -54,6 +67,7 @@ type t<'state> =
54
67
  tells apart. */
55
68
  let allowedStates = (transition: t<'state>): option<array<'state>> =>
56
69
  switch transition {
70
+ | Undeclared
57
71
  | Unrestricted
58
72
  | Creates(_) => None
59
73
  | Guards(states)
@@ -63,8 +77,21 @@ let allowedStates = (transition: t<'state>): option<array<'state>> =>
63
77
  /** The state the command's handler writes, or `None` for one that moves nothing. */
64
78
  let targetState = (transition: t<'state>): option<'state> =>
65
79
  switch transition {
80
+ | Undeclared
66
81
  | Unrestricted
67
82
  | Guards(_) => None
68
83
  | Creates(state)
69
84
  | Moves(_, state) => Some(state)
70
85
  }
86
+
87
+ /** Whether the command is claimed legal in every state, as opposed to nothing
88
+ being claimed at all. The two erase to the same pair of `None`s above, so this
89
+ is the only thing that can tell a reader of the erased form which it holds. */
90
+ let isUnrestricted = (transition: t<'state>): bool =>
91
+ switch transition {
92
+ | Unrestricted => true
93
+ | Undeclared
94
+ | Creates(_)
95
+ | Guards(_)
96
+ | Moves(_, _) => false
97
+ }
@@ -7,11 +7,11 @@ function allowedStates(transition) {
7
7
  return;
8
8
  }
9
9
  switch (transition.TAG) {
10
- case "Creates" :
11
- return;
12
10
  case "Guards" :
13
11
  case "Moves" :
14
12
  return transition._0;
13
+ default:
14
+ return;
15
15
  }
16
16
  }
17
17
 
@@ -22,15 +22,24 @@ function targetState(transition) {
22
22
  switch (transition.TAG) {
23
23
  case "Creates" :
24
24
  return Primitive_option.some(transition._0);
25
- case "Guards" :
26
- return;
27
25
  case "Moves" :
28
26
  return Primitive_option.some(transition._1);
27
+ default:
28
+ return;
29
+ }
30
+ }
31
+
32
+ function isUnrestricted(transition) {
33
+ if (typeof transition !== "object") {
34
+ return transition === "Unrestricted";
35
+ } else {
36
+ return false;
29
37
  }
30
38
  }
31
39
 
32
40
  export {
33
41
  allowedStates,
34
42
  targetState,
43
+ isUnrestricted,
35
44
  }
36
45
  /* No side effect */