@reventlessdev/reventless-spec 3.0.0-alpha.137 → 3.0.0-alpha.139

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,28 @@
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.139 (2026-09-21)
7
+
8
+ ### Features
9
+
10
+ * **spec:** one Id type per identity ([203be38](https://github.com/ReventlessDev/reventless-core/commit/203be389b9047db0a7a0294fe3c1c04ba5119965))
11
+ * **spec:** the runtime reads identities ([417c5f6](https://github.com/ReventlessDev/reventless-core/commit/417c5f613b031a08c7849a81c75de6a8bbeb841b))
12
+
13
+
14
+ # 3.0.0-alpha.138 (2026-09-20)
15
+
16
+ * fix(spec)!: let an explicit partition tag declare an identity the name cannot ([01d82c0](https://github.com/ReventlessDev/reventless-core/commit/01d82c0714a31f31d55c466eae9100cf4dcc85f0))
17
+
18
+ ### BREAKING CHANGES
19
+
20
+ * a @partitionTag on a field whose name is not *Id / *Ids was
21
+ inert and now takes effect, so such a slice's partition key — and with it its
22
+ storage key, fence and read scope — changes to the annotated field. Nothing else
23
+ moves: the annotation was doing nothing before, so no slice that resolved a key
24
+ resolves a different one.
25
+
26
+
27
+
6
28
  # 3.0.0-alpha.137 (2026-09-20)
7
29
 
8
30
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.137",
3
+ "version": "3.0.0-alpha.139",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -34,8 +34,28 @@ The three rules (over the representation):
34
34
  on `ProductAdded`) are payload ⇒ not indexed ⇒ the sibling-leak GSI write never
35
35
  happens.
36
36
  */
37
- /** A `*Id` / `*Ids`-shaped field, identified by name only (no schema, no tag flag). */
38
- type idField = {name: string, isList: bool}
37
+ /** A field the slice graph treats as an entity identity.
38
+
39
+ Normally that is a `*Id` / `*Ids`-shaped name — the convention is the signal,
40
+ and this module stays schema-agnostic by taking the name alone.
41
+
42
+ `byTag` marks the exception: an identity the *name* does not declare, which an
43
+ adapter recognised from an explicit `@partitionTag`. A domain's own identifier
44
+ is often not suffixed — `sku`, `isbn`, `vin` — and without this the annotation
45
+ naming one would be extracted as a hint and then dropped, because `seedOf`
46
+ only honours a hint already among the produced keys. So the escape hatch would
47
+ have worked for every field except the ones that need it.
48
+
49
+ It is carried rather than folded in because removing the annotation removes
50
+ the identity, which is exactly what the redundancy check has to know: a hint
51
+ inference cannot reach without it is never redundant. */
52
+ type idField = {
53
+ name: string,
54
+ isList: bool,
55
+ byTag?: bool,
56
+ /** The tag key, when the field's type says it (an identity) rather than its name. */
57
+ key?: string,
58
+ }
39
59
 
40
60
  /** One variant arm: its constructor name and the `*Id` fields it carries. */
41
61
  type eventShape = {eventType: string, idFields: array<idField>}
@@ -83,10 +103,13 @@ type derived = {
83
103
  /**
84
104
  The tag key for a `*Id`-shaped field. A plural `*Ids: array<string>` shares the
85
105
  singular producer's key (trailing `s` stripped — `productIds` -> `productId`);
86
- a scalar `*Id` uses the field name verbatim. Mirrors the PPX's `*Ids` rule.
106
+ a scalar `*Id` uses the field name verbatim. Mirrors the PPX's `*Ids` rule. An
107
+ identity-typed field carries its key, whatever it is called.
87
108
  */
88
109
  let tagKeyOf = (f: idField): string =>
89
- if f.isList && f.name->String.endsWith("s") {
110
+ if f.key->Option.isSome {
111
+ f.key->Option.getUnsafe
112
+ } else if f.isList && f.name->String.endsWith("s") {
90
113
  f.name->String.slice(~start=0, ~end=f.name->String.length - 1)
91
114
  } else {
92
115
  f.name
@@ -6,7 +6,9 @@ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js
6
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
7
7
 
8
8
  function tagKeyOf(f) {
9
- if (f.isList && f.name.endsWith("s")) {
9
+ if (Stdlib_Option.isSome(f.key)) {
10
+ return f.key;
11
+ } else if (f.isList && f.name.endsWith("s")) {
10
12
  return f.name.slice(0, f.name.length - 1 | 0);
11
13
  } else {
12
14
  return f.name;
@@ -126,6 +126,23 @@ let dcbTagKeyOverrideId: S.Metadata.Id.t<string> = S.Metadata.Id.make(
126
126
  ~name="tagKeyOverride",
127
127
  )
128
128
 
129
+ /**
130
+ Type-preserving forms of the markers below, for a field whose schema is not a
131
+ bare `S.string` — an identity (`OrderId.schema->DcbTag.mark`) keeps its type and
132
+ its semantic, and the tag key follows the identity unless `markForKey` says
133
+ otherwise.
134
+ */
135
+ let mark = (schema: S.t<'a>): S.t<'a> => schema->S.Metadata.set(~id=dcbTagId, true)
136
+
137
+ let markForKey = (schema: S.t<'a>, ~key: string): S.t<'a> =>
138
+ schema->mark->S.Metadata.set(~id=dcbTagKeyOverrideId, key)
139
+
140
+ let markPartition = (schema: S.t<'a>): S.t<'a> =>
141
+ schema->mark->S.Metadata.set(~id=dcbPartitionTagId, true)
142
+
143
+ let markCrossPartition = (schema: S.t<'a>): S.t<'a> =>
144
+ schema->mark->S.Metadata.set(~id=dcbCrossPartitionId, true)
145
+
129
146
  /**
130
147
  A sury string schema annotated as a DCB tag field.
131
148
 
@@ -148,7 +165,7 @@ and array element types.
148
165
  ```
149
166
  */
150
167
  let string: S.t<string> =
151
- S.string->S.Metadata.set(~id=dcbTagId, true)
168
+ S.string->mark
152
169
 
153
170
  /**
154
171
  A sury string schema annotated as a DCB tag field with an explicit tag-key override.
@@ -171,8 +188,7 @@ annotations carrying a string payload.
171
188
  // productIds: ["p1", "p2"] → tags [{key: "productId", value: "p1"}, {key: "productId", value: "p2"}]
172
189
  ```
173
190
  */
174
- let stringForKey = (~key: string): S.t<string> =>
175
- S.string->S.Metadata.set(~id=dcbTagId, true)->S.Metadata.set(~id=dcbTagKeyOverrideId, key)
191
+ let stringForKey = (~key: string): S.t<string> => S.string->markForKey(~key)
176
192
 
177
193
  /**
178
194
  A sury int schema annotated as a DCB tag field.
@@ -198,7 +214,7 @@ optional (auto-selected) when only one tagged field exists.
198
214
  ```
199
215
  */
200
216
  let partition: S.t<string> =
201
- S.string->S.Metadata.set(~id=dcbTagId, true)->S.Metadata.set(~id=dcbPartitionTagId, true)
217
+ S.string->markPartition
202
218
 
203
219
  /**
204
220
  A sury string schema annotated as a DCB tag field with *cross-partition* read
@@ -224,7 +240,7 @@ entities but can be partitioned by only one (course-subscription capacity,
224
240
  ```
225
241
  */
226
242
  let crossPartition: S.t<string> =
227
- S.string->S.Metadata.set(~id=dcbTagId, true)->S.Metadata.set(~id=dcbCrossPartitionId, true)
243
+ S.string->markCrossPartition
228
244
 
229
245
  /**
230
246
  A sury string schema marking a field as a composite partition key member.
@@ -355,19 +371,21 @@ let isCrossPartitionTaggedArray = (fieldSchema: S.t<unknown>) =>
355
371
 
356
372
  /**
357
373
  Resolves the tag key for a scalar tagged field: the explicit override metadata if
358
- present, otherwise the field name.
374
+ present, else the field's identity, otherwise the field name.
359
375
  */
360
376
  let resolveTagKey = (fieldName: string, fieldSchema: S.t<unknown>): string =>
361
- S.Metadata.get(fieldSchema, ~id=dcbTagKeyOverrideId)->Option.getOr(fieldName)
377
+ switch S.Metadata.get(fieldSchema, ~id=dcbTagKeyOverrideId) {
378
+ | Some(key) => key
379
+ | None => fieldSchema->Semantic.identityKey->Option.getOr(fieldName)
380
+ }
362
381
 
363
382
  /**
364
- Resolves the tag key for an array tagged field. The override metadata sits on the
365
- inner element schema; falls back to the field name when no override is set.
383
+ Resolves the tag key for an array tagged field. The override metadata and the
384
+ identity sit on the inner element schema; falls back to the field name.
366
385
  */
367
386
  let resolveArrayTagKey = (fieldName: string, fieldSchema: S.t<unknown>): string =>
368
387
  switch fieldSchema {
369
- | Array({additionalItems: Schema(itemSchema)}) =>
370
- S.Metadata.get(itemSchema, ~id=dcbTagKeyOverrideId)->Option.getOr(fieldName)
388
+ | Array({additionalItems: Schema(itemSchema)}) => resolveTagKey(fieldName, itemSchema)
371
389
  | _ => fieldName
372
390
  }
373
391
 
@@ -970,51 +988,33 @@ let buildQueryFromCommand = (
970
988
  // --- Extract tagged field names from event schema ---
971
989
 
972
990
  /**
973
- Extracts the names of all DCB-tagged fields across all variants of an event schema.
974
-
975
- Returns a sorted, deduplicated list of field names annotated with
976
- `@s.matches(DcbTag.string)` or `@s.matches(DcbTag.int)`.
991
+ The tag keys of all DCB-tagged fields across all variants of an event schema,
992
+ resolved as the tags themselves are, so an index is named after the key its tags
993
+ carry. Sorted and deduplicated.
977
994
 
978
995
  For `CatalogEventLog.event` returns `["categoryId", "productId"]`.
979
996
  */
980
997
  let extractTaggedFields = (schema: S.t<'event>): array<string> => {
981
- switch schema->toUnknownSchema {
982
- | AnyOf({anyOf}) =>
983
- // For union types, collect tagged fields from all variants
984
- let allFields = anyOf->Array.flatMap(variantSchema =>
985
- switch variantSchema {
986
- | Object({properties}) =>
987
- properties
988
- ->Dict.toArray
989
- ->Array.filterMap(((fieldName, fieldSchema)) =>
990
- if isTagged(fieldSchema) {
991
- Some(fieldName)
992
- } else {
993
- None
994
- }
995
- )
996
- | _ => []
997
- }
998
- )
999
- // Deduplicate field names using Set
1000
- let fieldSet = Set.make()
1001
- allFields->Array.forEach(field => fieldSet->Set.add(field))
1002
- Array.fromIterator(fieldSet->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
1003
-
1004
- | Object({properties}) =>
998
+ let ofProperties = properties =>
1005
999
  properties
1006
1000
  ->Dict.toArray
1007
1001
  ->Array.filterMap(((fieldName, fieldSchema)) =>
1008
- if isTagged(fieldSchema) {
1009
- Some(fieldName)
1010
- } else {
1011
- None
1002
+ isTagged(fieldSchema) ? Some(resolveTagKey(fieldName, fieldSchema)) : None
1003
+ )
1004
+ let keys = switch schema->toUnknownSchema {
1005
+ | AnyOf({anyOf}) =>
1006
+ anyOf->Array.flatMap(variantSchema =>
1007
+ switch variantSchema {
1008
+ | Object({properties}) => ofProperties(properties)
1009
+ | _ => []
1012
1010
  }
1013
1011
  )
1014
- ->Array.toSorted((a, b) => String.compare(a, b))
1015
-
1012
+ | Object({properties}) => ofProperties(properties)
1016
1013
  | _ => []
1017
1014
  }
1015
+ let seen = Set.make()
1016
+ keys->Array.forEach(k => seen->Set.add(k))
1017
+ Array.fromIterator(seen->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
1018
1018
  }
1019
1019
 
1020
1020
  /**
@@ -1084,29 +1084,56 @@ which is this module's half of the split.
1084
1084
  */
1085
1085
  let idFieldsOfProperties = (properties: dict<S.t<unknown>>): array<DcbScopeInference.idField> => {
1086
1086
  let isIdName = (name: string) => name->String.endsWith("Ids") || name->String.endsWith("Id")
1087
- properties
1088
- ->Dict.toArray
1089
- ->Array.flatMap(((name, fieldSchema)) =>
1087
+ // An explicit `@partitionTag` declares an identity the naming convention cannot
1088
+ // express — a domain's own identifier is often unsuffixed (`sku`, `isbn`).
1089
+ // Without this the hint is extracted and then ignored, since `seedOf` honours
1090
+ // only a hint already among the produced keys, so the escape hatch would work
1091
+ // for every field except the ones that need it. Flagged, not folded in: taking
1092
+ // the annotation away takes the identity with it, which is what the redundancy
1093
+ // check has to be able to tell.
1094
+ // A field's type outranks its name: `buyer: CustomerId.t` is keyed `customerId`,
1095
+ // and so is `orderId: CustomerId.t`. An explicit `@dcbTag("k")` still wins.
1096
+ let typedKey = (fieldSchema: S.t<unknown>, isList) => {
1097
+ let valueSchema = switch (isList, fieldSchema) {
1098
+ | (true, Array({additionalItems: Schema(item)})) => item
1099
+ | _ => fieldSchema
1100
+ }
1101
+ valueSchema->Semantic.identityKey->Option.map(_ => resolveTagKey("", valueSchema))
1102
+ }
1103
+ let untypedIdentity = (name, fieldSchema, isList) =>
1090
1104
  if isIdName(name) {
1091
- let isList = switch fieldSchema {
1092
- | Array(_) => true
1093
- | _ => false
1094
- }
1095
- [{DcbScopeInference.name, isList}]
1105
+ Some({DcbScopeInference.name, isList})
1106
+ } else if isPartitionTag(fieldSchema) {
1107
+ Some({DcbScopeInference.name, isList, byTag: true})
1096
1108
  } else {
1109
+ None
1110
+ }
1111
+ let identity = (name, fieldSchema, isList) =>
1112
+ switch typedKey(fieldSchema, isList) {
1113
+ | Some(key) => Some({DcbScopeInference.name, isList, key})
1114
+ | None => untypedIdentity(name, fieldSchema, isList)
1115
+ }
1116
+ properties
1117
+ ->Dict.toArray
1118
+ ->Array.flatMap(((name, fieldSchema)) => {
1119
+ let isList = switch fieldSchema {
1120
+ | Array(_) => true
1121
+ | _ => false
1122
+ }
1123
+ switch identity(name, fieldSchema, isList) {
1124
+ | Some(f) => [f]
1125
+ | None =>
1097
1126
  switch nestedRecordProperties(fieldSchema) {
1098
1127
  | Some((nested, nestedIsList)) =>
1099
1128
  nested
1100
1129
  ->Dict.toArray
1101
- ->Array.filterMap(((nestedName, _)) =>
1102
- isIdName(nestedName)
1103
- ? Some({DcbScopeInference.name: nestedName, isList: nestedIsList})
1104
- : None
1130
+ ->Array.filterMap(((nestedName, nestedSchema)) =>
1131
+ identity(nestedName, nestedSchema, nestedIsList)
1105
1132
  )
1106
1133
  | None => []
1107
1134
  }
1108
1135
  }
1109
- )
1136
+ })
1110
1137
  }
1111
1138
 
1112
1139
  /**
@@ -1135,7 +1162,8 @@ let eventShapesOfSchema = (schema: S.t<'a>): array<DcbScopeInference.eventShape>
1135
1162
  // --- Partition tag derivation ---
1136
1163
 
1137
1164
  /**
1138
- Extracts field names annotated with `@s.matches(DcbTag.partition)` from an event schema.
1165
+ Extracts the tag keys of fields annotated with `@s.matches(DcbTag.partition)` from an
1166
+ event schema: the field name, unless an override or an identity says otherwise.
1139
1167
  */
1140
1168
  let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
1141
1169
  switch schema->toUnknownSchema {
@@ -1147,7 +1175,7 @@ let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
1147
1175
  ->Dict.toArray
1148
1176
  ->Array.filterMap(((fieldName, fieldSchema)) =>
1149
1177
  if isPartitionTag(fieldSchema) {
1150
- Some(fieldName)
1178
+ Some(resolveTagKey(fieldName, fieldSchema))
1151
1179
  } else {
1152
1180
  None
1153
1181
  }
@@ -1164,7 +1192,7 @@ let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
1164
1192
  ->Dict.toArray
1165
1193
  ->Array.filterMap(((fieldName, fieldSchema)) =>
1166
1194
  if isPartitionTag(fieldSchema) {
1167
- Some(fieldName)
1195
+ Some(resolveTagKey(fieldName, fieldSchema))
1168
1196
  } else {
1169
1197
  None
1170
1198
  }
@@ -32,17 +32,33 @@ let dcbCompositePartitionMemberId = Sury.$Metadata_Id_make("dcb", "compositePart
32
32
 
33
33
  let dcbTagKeyOverrideId = Sury.$Metadata_Id_make("dcb", "tagKeyOverride");
34
34
 
35
+ function mark(schema) {
36
+ return Sury.$Metadata_set(schema, dcbTagId, true);
37
+ }
38
+
39
+ function markForKey(schema, key) {
40
+ return Sury.$Metadata_set(Sury.$Metadata_set(schema, dcbTagId, true), dcbTagKeyOverrideId, key);
41
+ }
42
+
43
+ function markPartition(schema) {
44
+ return Sury.$Metadata_set(Sury.$Metadata_set(schema, dcbTagId, true), dcbPartitionTagId, true);
45
+ }
46
+
47
+ function markCrossPartition(schema) {
48
+ return Sury.$Metadata_set(Sury.$Metadata_set(schema, dcbTagId, true), dcbCrossPartitionId, true);
49
+ }
50
+
35
51
  let string = Sury.$Metadata_set(Sury.string, dcbTagId, true);
36
52
 
37
53
  function stringForKey(key) {
38
- return Sury.$Metadata_set(Sury.$Metadata_set(Sury.string, dcbTagId, true), dcbTagKeyOverrideId, key);
54
+ return markForKey(Sury.string, key);
39
55
  }
40
56
 
41
57
  let int = Sury.$Metadata_set(Sury.int, dcbTagId, true);
42
58
 
43
- let partition = Sury.$Metadata_set(Sury.$Metadata_set(Sury.string, dcbTagId, true), dcbPartitionTagId, true);
59
+ let partition = markPartition(Sury.string);
44
60
 
45
- let crossPartition = Sury.$Metadata_set(Sury.$Metadata_set(Sury.string, dcbTagId, true), dcbCrossPartitionId, true);
61
+ let crossPartition = markCrossPartition(Sury.string);
46
62
 
47
63
  function compositePartitionMember(position, sepOpt) {
48
64
  let sep = sepOpt !== undefined ? sepOpt : "/";
@@ -169,7 +185,12 @@ function isCrossPartitionTaggedArray(fieldSchema) {
169
185
  }
170
186
 
171
187
  function resolveTagKey(fieldName, fieldSchema) {
172
- return Stdlib_Option.getOr(Sury.$Metadata_get(fieldSchema, dcbTagKeyOverrideId), fieldName);
188
+ let key = Sury.$Metadata_get(fieldSchema, dcbTagKeyOverrideId);
189
+ if (key !== undefined) {
190
+ return key;
191
+ } else {
192
+ return Stdlib_Option.getOr(Semantic$Reventless.identityKey(fieldSchema), fieldName);
193
+ }
173
194
  }
174
195
 
175
196
  function resolveArrayTagKey(fieldName, fieldSchema) {
@@ -180,7 +201,7 @@ function resolveArrayTagKey(fieldName, fieldSchema) {
180
201
  if (itemSchema === "strip" || itemSchema === "strict") {
181
202
  return fieldName;
182
203
  } else {
183
- return Stdlib_Option.getOr(Sury.$Metadata_get(itemSchema, dcbTagKeyOverrideId), fieldName);
204
+ return resolveTagKey(fieldName, itemSchema);
184
205
  }
185
206
  }
186
207
 
@@ -627,33 +648,34 @@ function buildQueryFromCommand(eventTypes, schema, value, tagKeysByEventTypeOpt,
627
648
  }
628
649
 
629
650
  function extractTaggedFields(schema) {
651
+ let ofProperties = properties => Stdlib_Array.filterMap(Object.entries(properties), param => {
652
+ let fieldSchema = param[1];
653
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbTagId))) {
654
+ return resolveTagKey(param[0], fieldSchema);
655
+ }
656
+ });
657
+ let keys;
630
658
  switch (schema.type) {
631
659
  case "object" :
632
- return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
633
- if (Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbTagId))) {
634
- return param[0];
635
- }
636
- }).toSorted(Primitive_string.compare);
660
+ keys = ofProperties(schema.properties);
661
+ break;
637
662
  case "anyOf" :
638
- let allFields = schema.anyOf.flatMap(variantSchema => {
663
+ keys = schema.anyOf.flatMap(variantSchema => {
639
664
  if (variantSchema.type === "object") {
640
- return Stdlib_Array.filterMap(Object.entries(variantSchema.properties), param => {
641
- if (Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbTagId))) {
642
- return param[0];
643
- }
644
- });
665
+ return ofProperties(variantSchema.properties);
645
666
  } else {
646
667
  return [];
647
668
  }
648
669
  });
649
- let fieldSet = new Set();
650
- allFields.forEach(field => {
651
- fieldSet.add(field);
652
- });
653
- return Array.from(fieldSet.values()).toSorted(Primitive_string.compare);
670
+ break;
654
671
  default:
655
- return [];
672
+ keys = [];
656
673
  }
674
+ let seen = new Set();
675
+ keys.forEach(k => {
676
+ seen.add(k);
677
+ });
678
+ return Array.from(seen.values()).toSorted(Primitive_string.compare);
657
679
  }
658
680
 
659
681
  function crossPartitionKeysOfProperties(properties) {
@@ -703,31 +725,53 @@ function idFieldsOfProperties(properties) {
703
725
  return name.endsWith("Id");
704
726
  }
705
727
  };
728
+ let typedKey = (fieldSchema, isList) => {
729
+ let valueSchema;
730
+ if (isList && fieldSchema.type === "array") {
731
+ let item = fieldSchema.additionalItems;
732
+ valueSchema = item === "strip" || item === "strict" ? fieldSchema : item;
733
+ } else {
734
+ valueSchema = fieldSchema;
735
+ }
736
+ return Stdlib_Option.map(Semantic$Reventless.identityKey(valueSchema), param => resolveTagKey("", valueSchema));
737
+ };
738
+ let identity = (name, fieldSchema, isList) => {
739
+ let key = typedKey(fieldSchema, isList);
740
+ if (key !== undefined) {
741
+ return {
742
+ name: name,
743
+ isList: isList,
744
+ key: key
745
+ };
746
+ } else if (isIdName(name)) {
747
+ return {
748
+ name: name,
749
+ isList: isList
750
+ };
751
+ } else if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbPartitionTagId))) {
752
+ return {
753
+ name: name,
754
+ isList: isList,
755
+ byTag: true
756
+ };
757
+ } else {
758
+ return;
759
+ }
760
+ };
706
761
  return Object.entries(properties).flatMap(param => {
707
762
  let fieldSchema = param[1];
708
- let name = param[0];
709
- if (isIdName(name)) {
710
- let isList;
711
- isList = fieldSchema.type === "array";
712
- return [{
713
- name: name,
714
- isList: isList
715
- }];
763
+ let isList;
764
+ isList = fieldSchema.type === "array";
765
+ let f = identity(param[0], fieldSchema, isList);
766
+ if (f !== undefined) {
767
+ return [f];
716
768
  }
717
769
  let match = nestedRecordProperties(fieldSchema);
718
770
  if (match === undefined) {
719
771
  return [];
720
772
  }
721
773
  let nestedIsList = match[1];
722
- return Stdlib_Array.filterMap(Object.entries(match[0]), param => {
723
- let nestedName = param[0];
724
- if (isIdName(nestedName)) {
725
- return {
726
- name: nestedName,
727
- isList: nestedIsList
728
- };
729
- }
730
- });
774
+ return Stdlib_Array.filterMap(Object.entries(match[0]), param => identity(param[0], param[1], nestedIsList));
731
775
  });
732
776
  }
733
777
 
@@ -768,16 +812,18 @@ function extractPartitionTagFields(schema) {
768
812
  switch (schema.type) {
769
813
  case "object" :
770
814
  return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
771
- if (Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbPartitionTagId))) {
772
- return param[0];
815
+ let fieldSchema = param[1];
816
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbPartitionTagId))) {
817
+ return resolveTagKey(param[0], fieldSchema);
773
818
  }
774
819
  });
775
820
  case "anyOf" :
776
821
  let allFields = schema.anyOf.flatMap(variantSchema => {
777
822
  if (variantSchema.type === "object") {
778
823
  return Stdlib_Array.filterMap(Object.entries(variantSchema.properties), param => {
779
- if (Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbPartitionTagId))) {
780
- return param[0];
824
+ let fieldSchema = param[1];
825
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbPartitionTagId))) {
826
+ return resolveTagKey(param[0], fieldSchema);
781
827
  }
782
828
  });
783
829
  } else {
@@ -1038,6 +1084,10 @@ export {
1038
1084
  dcbCrossPartitionId,
1039
1085
  dcbCompositePartitionMemberId,
1040
1086
  dcbTagKeyOverrideId,
1087
+ mark,
1088
+ markForKey,
1089
+ markPartition,
1090
+ markCrossPartition,
1041
1091
  string,
1042
1092
  stringForKey,
1043
1093
  int,
@@ -501,29 +501,62 @@ let validatePartitionHintsVsInference = (
501
501
  shapes->Array.forEach(s =>
502
502
  switch s.partitionHint {
503
503
  | Some(hint) if DcbScopeInference.producedKeys(s)->Array.includes(hint) =>
504
+ // "Without the annotation" has to mean without everything the annotation
505
+ // brought. A field that is an identity only because it carries the tag
506
+ // (`byTag`) stops being one when the tag goes, so dropping only the hint
507
+ // would ask whether inference reaches a key it can no longer see — and
508
+ // answer yes, reporting a load-bearing annotation as removable.
509
+ let withoutTag = (e: DcbScopeInference.eventShape) => {
510
+ ...e,
511
+ idFields: e.idFields->Array.filter(f => f.byTag != Some(true)),
512
+ }
513
+ // A hint naming an identity that exists only because of the annotation is
514
+ // **necessary**, and fits neither verdict this check was built to give.
515
+ // Called redundant it would be removed and the key would vanish; called
516
+ // contradictory it would be "corrected" to whatever name-shaped field
517
+ // happens to sit beside it — which is the author's choice overruled, not a
518
+ // mistake found. Both readings come from assuming the field is an identity
519
+ // either way, which is true only when the name says so.
520
+ let declaredByTag =
521
+ s.produced->Array.some(e =>
522
+ e.idFields->Array.some(f => f.name == hint && f.byTag == Some(true))
523
+ )
504
524
  let unaided = DcbScopeInference.resolvePartitions(
505
- shapes->Array.map(o => o.sliceName == s.sliceName ? {...o, partitionHint: None} : o),
525
+ shapes->Array.map(o =>
526
+ o.sliceName == s.sliceName
527
+ ? {
528
+ ...o,
529
+ partitionHint: None,
530
+ produced: o.produced->Array.map(withoutTag),
531
+ consumed: o.consumed->Array.map(withoutTag),
532
+ }
533
+ : o
534
+ ),
506
535
  )
507
- switch unaided.partitionBySlice->Dict.get(s.sliceName) {
508
- | Some(inferred) if inferred == hint =>
509
- redundancies->Array.push({
510
- sliceName: s.sliceName,
511
- message: `@partitionTag ${hint} is what inference derives without it — the annotation is redundant and can be removed.`,
512
- })
513
- | Some(inferred) =>
514
- contradictions->Array.push({
515
- sliceName: s.sliceName,
516
- message: `@partitionTag names ${hint}, but inference derives ${inferred} from the slice graph — ${hint} is read from another entity. Remove the annotation, or move it to ${inferred}.`,
517
- })
518
- | None =>
519
- let candidates = unaided.candidatesBySlice->Dict.get(s.sliceName)->Option.getOr([])
520
- if !(candidates->Array.includes(hint)) {
536
+ if declaredByTag {
537
+ ()
538
+ } else {
539
+ switch unaided.partitionBySlice->Dict.get(s.sliceName) {
540
+ | Some(inferred) if inferred == hint =>
541
+ redundancies->Array.push({
542
+ sliceName: s.sliceName,
543
+ message: `@partitionTag ${hint} is what inference derives without it — the annotation is redundant and can be removed.`,
544
+ })
545
+ | Some(inferred) =>
521
546
  contradictions->Array.push({
522
547
  sliceName: s.sliceName,
523
- message: `@partitionTag names ${hint}, which this slice only reads as a reference to another entity (candidates: ${candidates->Array.join(
524
- ", ",
525
- )}). Move the annotation to the slice's own key.`,
548
+ message: `@partitionTag names ${hint}, but inference derives ${inferred} from the slice graph — ${hint} is read from another entity. Remove the annotation, or move it to ${inferred}.`,
526
549
  })
550
+ | None =>
551
+ let candidates = unaided.candidatesBySlice->Dict.get(s.sliceName)->Option.getOr([])
552
+ if !(candidates->Array.includes(hint)) {
553
+ contradictions->Array.push({
554
+ sliceName: s.sliceName,
555
+ message: `@partitionTag names ${hint}, which this slice only reads as a reference to another entity (candidates: ${candidates->Array.join(
556
+ ", ",
557
+ )}). Move the annotation to the slice's own key.`,
558
+ })
559
+ }
527
560
  }
528
561
  }
529
562
  | _ => ()
@@ -437,14 +437,30 @@ function validatePartitionHintsVsInference(shapes) {
437
437
  if (!DcbScopeInference$Reventless.producedKeys(s).includes(hint)) {
438
438
  return;
439
439
  }
440
+ let withoutTag = e => ({
441
+ eventType: e.eventType,
442
+ idFields: e.idFields.filter(f => Primitive_object.notequal(f.byTag, true))
443
+ });
444
+ let declaredByTag = s.produced.some(e => e.idFields.some(f => {
445
+ if (f.name === hint) {
446
+ return Primitive_object.equal(f.byTag, true);
447
+ } else {
448
+ return false;
449
+ }
450
+ }));
440
451
  let unaided = DcbScopeInference$Reventless.resolvePartitions(shapes.map(o => {
441
452
  if (o.sliceName !== s.sliceName) {
442
453
  return o;
443
454
  }
444
455
  let newrecord = {...o};
445
456
  newrecord.partitionHint = undefined;
457
+ newrecord.produced = o.produced.map(withoutTag);
458
+ newrecord.consumed = o.consumed.map(withoutTag);
446
459
  return newrecord;
447
460
  }));
461
+ if (declaredByTag) {
462
+ return;
463
+ }
448
464
  let inferred = unaided.partitionBySlice[s.sliceName];
449
465
  if (inferred !== undefined) {
450
466
  if (inferred === hint) {
@@ -39,6 +39,32 @@ let to_ = (~plugin=?, ~key=?, entity: string): S.t<string> => {
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ `to_` for a field whose schema is not a bare `S.string`: the schema keeps its
44
+ type, and an identity it carries moves onto the reference's target rather than
45
+ being overwritten.
46
+ */
47
+ let mark = (schema: S.t<'a>, ~plugin=?, ~key=?, entity: string): S.t<'a> => {
48
+ let identity = Semantic.identityKey(schema)
49
+ let base =
50
+ schema
51
+ ->S.Metadata.set(~id=DcbTag.dcbTagId, true)
52
+ ->Semantic.mark(~id=Semantic.Id.reference, ~payload=ReferenceTo({entity, plugin, ?identity}))
53
+ switch key {
54
+ | Some(k) => base->S.Metadata.set(~id=DcbTag.dcbTagKeyOverrideId, k)
55
+ | None => base
56
+ }
57
+ }
58
+
59
+ /** `toWithoutDcbTag` for a field whose schema is not a bare `S.string`. */
60
+ let markWithoutDcbTag = (schema: S.t<'a>, ~plugin=?, entity: string): S.t<'a> => {
61
+ let identity = Semantic.identityKey(schema)
62
+ schema->Semantic.mark(
63
+ ~id=Semantic.Id.reference,
64
+ ~payload=ReferenceTo({entity, plugin, ?identity}),
65
+ )
66
+ }
67
+
42
68
  /** Returns the reference target if the schema carries `Reference.to_(...)` metadata. */
43
69
  let getTarget = (schema: S.t<unknown>): option<target> =>
44
70
  switch Semantic.get(schema) {
@@ -21,6 +21,35 @@ function to_(plugin, key, entity) {
21
21
  }
22
22
  }
23
23
 
24
+ function mark(schema, plugin, key, entity) {
25
+ let identity = Semantic$Reventless.identityKey(schema);
26
+ let base = Semantic$Reventless.mark(Sury.$Metadata_set(schema, DcbTag$Reventless.dcbTagId, true), Semantic$Reventless.Id.reference, {
27
+ TAG: "ReferenceTo",
28
+ _0: {
29
+ entity: entity,
30
+ plugin: plugin,
31
+ identity: identity
32
+ }
33
+ });
34
+ if (key !== undefined) {
35
+ return Sury.$Metadata_set(base, DcbTag$Reventless.dcbTagKeyOverrideId, key);
36
+ } else {
37
+ return base;
38
+ }
39
+ }
40
+
41
+ function markWithoutDcbTag(schema, plugin, entity) {
42
+ let identity = Semantic$Reventless.identityKey(schema);
43
+ return Semantic$Reventless.mark(schema, Semantic$Reventless.Id.reference, {
44
+ TAG: "ReferenceTo",
45
+ _0: {
46
+ entity: entity,
47
+ plugin: plugin,
48
+ identity: identity
49
+ }
50
+ });
51
+ }
52
+
24
53
  function getTarget(schema) {
25
54
  let match = Semantic$Reventless.get(schema);
26
55
  if (match === undefined) {
@@ -92,6 +121,8 @@ function toWithoutDcbTag(plugin, entity) {
92
121
 
93
122
  export {
94
123
  to_,
124
+ mark,
125
+ markWithoutDcbTag,
95
126
  getTarget,
96
127
  getFieldTarget,
97
128
  collectFieldTargets,
@@ -10,7 +10,7 @@ The payload is a typed variant because the vocabulary is framework-owned and
10
10
  closed — which also keeps `Reference.getTarget` total.
11
11
  */
12
12
  /** Which entity a reference field points to. */
13
- type referenceTarget = {entity: string, plugin: option<string>}
13
+ type referenceTarget = {entity: string, plugin: option<string>, identity?: string}
14
14
 
15
15
  /** Which object store the value lives in. `plugin` is absent for the declaring
16
16
  plugin's own store; `threshold` is `@offload`'s per-field byte cut, `None`
@@ -50,6 +50,8 @@ type payload =
50
50
  | ReferenceTo(referenceTarget)
51
51
  | StoredIn(storeTarget)
52
52
  | MemberOf(memberTarget)
53
+ /** Which identity an id is: named by its key (`orderId`). */
54
+ | IdentityOf({key: string})
53
55
 
54
56
  /** A field's semantic: the vocabulary id, plus its detail. */
55
57
  type t = {id: string, payload: payload}
@@ -111,6 +113,10 @@ module Id = {
111
113
  // The first composite that is a union rather than an object. Collapses fields,
112
114
  // so adopting it changes the wire and rebuilds a derived view.
113
115
  let geolocation = "geolocation"
116
+
117
+ // Which entity an id names, carried by the type `Id.Make` produces. A string on
118
+ // the wire, so adopting one changes nothing stored.
119
+ let identity = "identity"
114
120
  }
115
121
 
116
122
  /** One transparent-string semantic: a `semantic/` module whose `type t` is a
@@ -242,6 +248,16 @@ let rec getFrom = (schema: S.t<unknown>): option<t> =>
242
248
 
243
249
  let get = (fieldSchema: S.t<'a>): option<t> => fieldSchema->S.castToUnknown->getFrom
244
250
 
251
+ /** The identity a field's value is (`orderId`), read through `option<…>`. A
252
+ reference to an identity carries it on its target, since a schema holds one
253
+ semantic. */
254
+ let identityKey = (fieldSchema: S.t<'a>): option<string> =>
255
+ switch get(fieldSchema) {
256
+ | Some({payload: IdentityOf({key})}) => Some(key)
257
+ | Some({payload: ReferenceTo({?identity})}) => identity
258
+ | _ => None
259
+ }
260
+
245
261
  /** Whether a field's schema carries this specific semantic. */
246
262
  let has = (fieldSchema: S.t<'a>, ~id: string): bool =>
247
263
  switch get(fieldSchema) {
@@ -51,7 +51,8 @@ let Id = {
51
51
  dateRange: "dateRange",
52
52
  geoPoint: "geoPoint",
53
53
  lifecycleTrail: "lifecycleTrail",
54
- geolocation: "geolocation"
54
+ geolocation: "geolocation",
55
+ identity: "identity"
55
56
  };
56
57
 
57
58
  let brandedStrings = [
@@ -205,6 +206,25 @@ function getFrom(schema) {
205
206
 
206
207
  let get = getFrom;
207
208
 
209
+ function identityKey(fieldSchema) {
210
+ let match = getFrom(fieldSchema);
211
+ if (match === undefined) {
212
+ return;
213
+ }
214
+ let match$1 = match.payload;
215
+ if (typeof match$1 !== "object") {
216
+ return;
217
+ }
218
+ switch (match$1.TAG) {
219
+ case "ReferenceTo" :
220
+ return match$1._0.identity;
221
+ case "IdentityOf" :
222
+ return match$1.key;
223
+ default:
224
+ return;
225
+ }
226
+ }
227
+
208
228
  function has(fieldSchema, id) {
209
229
  let s = getFrom(fieldSchema);
210
230
  if (s !== undefined) {
@@ -225,6 +245,7 @@ export {
225
245
  unionVariant,
226
246
  getFrom,
227
247
  get,
248
+ identityKey,
228
249
  has,
229
250
  }
230
251
  /* semanticId Not a pure module */
package/src/types/Id.res CHANGED
@@ -2,8 +2,9 @@
2
2
  Module type for aggregate and read model identifiers.
3
3
 
4
4
  Every Reventless component declares its own `Id` module satisfying this type.
5
- The abstract `type t` prevents accidentally mixing identifiers from different
6
- aggregates at compile time.
5
+ The abstract `type t` keeps ids apart from plain strings, but not from each
6
+ other: every alias of `Id.String` (which is what the ppx injects) is the same
7
+ type. Only `Id.Make` gives an entity an id type of its own.
7
8
 
8
9
  @example
9
10
  ```rescript
@@ -62,8 +63,9 @@ module StringPure = {
62
63
  /**
63
64
  A sealed string-based `Id.T` implementation for use in production aggregate specs.
64
65
 
65
- Unlike `StringPure`, the `t` type is abstract, preventing accidental cross-aggregate
66
- ID mixing. Use `Id.StringPure` in tests when string literals are needed.
66
+ Unlike `StringPure`, the `t` type is abstract, so an id is not a plain string. It is
67
+ sealed once, so every alias shares one type; use `Id.Make` for an id type per
68
+ entity. Use `Id.StringPure` in tests when string literals are needed.
67
69
 
68
70
  @example
69
71
  ```rescript
@@ -73,3 +75,31 @@ let name = "Category"
73
75
  ```
74
76
  */
75
77
  module String: T = StringPure
78
+
79
+ /** An `Id.T` that names one entity, by its key (`orderId`). */
80
+ module type Identity = {
81
+ include T with type input = string
82
+ let key: string
83
+ }
84
+
85
+ /**
86
+ One distinct type per identity. Each application yields a fresh abstract `t`, so
87
+ an `OrderId.t` cannot be passed where a `CustomerId.t` is expected. A string on
88
+ the wire; the schema carries the `identity` semantic, which is how the runtime
89
+ knows which entity a field names whatever the field is called.
90
+
91
+ @example
92
+ ```rescript
93
+ // src/Order/OrderId.res
94
+ include Reventless.Id.Make({let key = "orderId"})
95
+ ```
96
+ */
97
+ module Make = (
98
+ K: {
99
+ let key: string
100
+ },
101
+ ): Identity => {
102
+ include StringPure
103
+ let key = K.key
104
+ let schema = schema->Semantic.mark(~id=Semantic.Id.identity, ~payload=IdentityOf({key: K.key}))
105
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Sury from "sury";
4
4
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
5
+ import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
5
6
 
6
7
  let schema = Sury.string;
7
8
 
@@ -12,6 +13,21 @@ let StringPure = {
12
13
  cmp: cmp
13
14
  };
14
15
 
16
+ function Make(K) {
17
+ let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.identity, {
18
+ TAG: "IdentityOf",
19
+ key: K.key
20
+ });
21
+ return {
22
+ schema: schema$1,
23
+ make: prim => prim,
24
+ makeFromString: prim => prim,
25
+ toString: prim => prim,
26
+ cmp: cmp,
27
+ key: K.key
28
+ };
29
+ }
30
+
15
31
  function String_make(prim) {
16
32
  return prim;
17
33
  }
@@ -35,5 +51,6 @@ let $$String = {
35
51
  export {
36
52
  StringPure,
37
53
  $$String,
54
+ Make,
38
55
  }
39
56
  /* schema Not a pure module */