@reventlessdev/reventless-spec 3.0.0-alpha.128 → 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,62 @@
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
+
22
+ # 3.0.0-alpha.129 (2026-09-04)
23
+
24
+ ### Bug Fixes
25
+
26
+ * **logging:** let a runtime declare whether a person reads its logs ([df7a6cb](https://github.com/ReventlessDev/reventless-core/commit/df7a6cbdff62b856ea6b9d1f169b913563ba13a1))
27
+ * feat(spec)!: one optional encoding on the wire, with no annotation ([320f91d](https://github.com/ReventlessDev/reventless-core/commit/320f91daa8bd90812a6e82069e7a1cb473041930))
28
+
29
+ ### BREAKING CHANGES
30
+
31
+ * the two encodings cannot read each other. Stored
32
+ pluginDefinition / pluginStructure payloads and already-deployed plugins must
33
+ go — wipe the platform scope (SEED_RESET_SCOPE=platform), quiesce, and
34
+ redeploy the fleet from one commit. Domain plugin data is untouched.
35
+
36
+ Two guards had to learn the new shape, both of which defined "optional" as
37
+ has.null and so mistook an omitted key for something to invent:
38
+
39
+ - Message.fillMissingDefaults reached `return undefined` only below two arms
40
+ that fire first — an enum's first const, and an object member filled with
41
+ zeros. Absent option<record> therefore healed to a zero-filled record, not
42
+ None: on the real schema, dcbEventLog became Some({name: "", eventTopicArn:
43
+ ""}), which manageSubscriptions would have read as a peer to subscribe to.
44
+ One line, mirroring the has.null guard, above both arms.
45
+ - PluginDefinitionScalars' walker reported 16 optional fields as newly-added
46
+ bare required scalars. With both encodings understood, the golden list is
47
+ unchanged.
48
+
49
+ The frozen lifecycle corpus holds five real payloads in the old encoding.
50
+ Null-valued keys were stripped mechanically — the rewrite round-trips each
51
+ file unchanged before editing, and a key-by-key diff shows null removals and
52
+ nothing else. The README records it beside the account-id redaction and says
53
+ why it is not a regeneration: no fixture was rebuilt from ReScript types, so
54
+ every generation in its table is still pinned.
55
+
56
+ check:graphql is unchanged, as expected: SchemaType.fromSury collapses Null
57
+ and Undefined to the same Nullable, so the emitted schema never distinguished
58
+ them.
59
+
60
+
61
+
6
62
  # 3.0.0-alpha.128 (2026-09-04)
7
63
 
8
64
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.128",
3
+ "version": "3.0.0-alpha.130",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "sury": "11.0.0-rc.2",
25
25
  "sury-ppx": "11.0.0-rc.2",
26
26
  "yaml": "^2.8.3",
27
- "@reventlessdev/rescript-node": "2.0.0-alpha.8"
27
+ "@reventlessdev/rescript-node": "2.0.0-alpha.9"
28
28
  },
29
29
  "devDependencies": {
30
30
  "rescript": "12.3.0",
@@ -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!
package/src/AnsiStyle.res CHANGED
@@ -11,14 +11,23 @@
11
11
  @val external _isTty: option<bool> = "process.stdout.isTTY"
12
12
  @val external _logFormat: option<string> = "process.env.REVENTLESS_LOG_FORMAT"
13
13
 
14
- // "json" | "text". Explicit REVENTLESS_LOG_FORMAT override always wins; otherwise
15
- // a TTY stdout ⇒ human-readable text, everything else (Lambda, Fargate, ECS,
16
- // Azure, GCP, Docker, CI runners, piped stdout) ⇒ structured JSON.
14
+ // What this runtime is, for when stdout cannot say. A local dev platform's logs
15
+ // are read by a person even when its stdout is a pipe — `concurrently`, `tsx
16
+ // watch` and an IDE terminal all pipe — while a Lambda's are read by a collector
17
+ // through a pipe that looks identical. TTY-ness is only a proxy for "a person is
18
+ // reading this", and a pipe is exactly where the proxy fails, so a runtime that
19
+ // knows which it is says so instead of being guessed at.
20
+ let _default: ref<option<string>> = ref(None)
21
+
22
+ // "json" | "text", in precedence order: an explicit REVENTLESS_LOG_FORMAT, then
23
+ // what the runtime declared itself to be, then the TTY probe — a TTY stdout ⇒
24
+ // text, everything else (Lambda, Fargate, ECS, Docker, CI) ⇒ structured JSON.
17
25
  let _resolveFormat = (): string =>
18
- switch (_logFormat, _isTty) {
19
- | (Some("json"), _) => "json"
20
- | (Some("text"), _) => "text"
21
- | (_, Some(true)) => "text"
26
+ switch (_logFormat, _default.contents, _isTty) {
27
+ | (Some("json"), _, _) => "json"
28
+ | (Some("text"), _, _) => "text"
29
+ | (_, Some(declared), _) => declared
30
+ | (_, None, Some(true)) => "text"
22
31
  | _ => "json"
23
32
  }
24
33
 
@@ -27,6 +36,21 @@ let _resolveFormat = (): string =>
27
36
  // keeps every log call off `process.env`.
28
37
  let _format = ref(_resolveFormat())
29
38
 
39
+ /** Declares what this runtime is, for the pipe case the TTY probe gets wrong.
40
+ Call it at module init, before anything logs. An explicit
41
+ REVENTLESS_LOG_FORMAT still wins, so a developer can always ask for the other
42
+ one. A runtime that says nothing keeps the TTY behaviour. */
43
+ let setDefaultFormat = (format: [#text | #json]) => {
44
+ _default :=
45
+ Some(
46
+ switch format {
47
+ | #text => "text"
48
+ | #json => "json"
49
+ },
50
+ )
51
+ _format := _resolveFormat()
52
+ }
53
+
30
54
  /** Test-only: re-evaluate REVENTLESS_LOG_FORMAT / process.stdout.isTTY. */
31
55
  let reload = () => _format := _resolveFormat()
32
56
 
@@ -1,9 +1,14 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
 
4
+ let _default = {
5
+ contents: undefined
6
+ };
7
+
4
8
  function _resolveFormat() {
5
9
  let match = process.env.REVENTLESS_LOG_FORMAT;
6
- let match$1 = process.stdout.isTTY;
10
+ let match$1 = _default.contents;
11
+ let match$2 = process.stdout.isTTY;
7
12
  if (match !== undefined) {
8
13
  switch (match) {
9
14
  case "json" :
@@ -12,7 +17,9 @@ function _resolveFormat() {
12
17
  return "text";
13
18
  }
14
19
  }
15
- if (match$1 !== undefined && match$1) {
20
+ if (match$1 !== undefined) {
21
+ return match$1;
22
+ } else if (match$2 !== undefined && match$2) {
16
23
  return "text";
17
24
  } else {
18
25
  return "json";
@@ -23,6 +30,11 @@ let _format = {
23
30
  contents: _resolveFormat()
24
31
  };
25
32
 
33
+ function setDefaultFormat(format) {
34
+ _default.contents = format === "text" ? "text" : "json";
35
+ _format.contents = _resolveFormat();
36
+ }
37
+
26
38
  function reload() {
27
39
  _format.contents = _resolveFormat();
28
40
  }
@@ -44,8 +56,10 @@ function bold(s) {
44
56
  }
45
57
 
46
58
  export {
59
+ _default,
47
60
  _resolveFormat,
48
61
  _format,
62
+ setDefaultFormat,
49
63
  reload,
50
64
  isJsonSink,
51
65
  useAnsi,
@@ -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