@reventlessdev/reventless-spec 3.0.0-alpha.138 → 3.0.0-alpha.140

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,30 @@
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.140 (2026-09-21)
7
+
8
+ * feat(spec)!: typed projection keys ([fc6a018](https://github.com/ReventlessDev/reventless-core/commit/fc6a01811ba788f3289e84a435a09f77fa627e1a))
9
+ ### Features
10
+
11
+ * **core:** references derived from identities, and identity checks ([7cb1376](https://github.com/ReventlessDev/reventless-core/commit/7cb1376e56cdb4e4d5ab5954cd97fd37fd3ba258))
12
+
13
+ ### BREAKING CHANGES
14
+
15
+ * a projection that keys a row by a payload `string`, or uses
16
+ the envelope id as a `string`, converts explicitly with
17
+ `Target.Id.makeFromString` / `Source.Id.toString`. `StateChangeSlice.Spec` no
18
+ longer requires `module Id`.
19
+
20
+
21
+
22
+ # 3.0.0-alpha.139 (2026-09-21)
23
+
24
+ ### Features
25
+
26
+ * **spec:** one Id type per identity ([203be38](https://github.com/ReventlessDev/reventless-core/commit/203be389b9047db0a7a0294fe3c1c04ba5119965))
27
+ * **spec:** the runtime reads identities ([417c5f6](https://github.com/ReventlessDev/reventless-core/commit/417c5f613b031a08c7849a81c75de6a8bbeb841b))
28
+
29
+
6
30
  # 3.0.0-alpha.138 (2026-09-20)
7
31
 
8
32
  * fix(spec)!: let an explicit partition tag declare an identity the name cannot ([01d82c0](https://github.com/ReventlessDev/reventless-core/commit/01d82c0714a31f31d55c466eae9100cf4dcc85f0))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.138",
3
+ "version": "3.0.0-alpha.140",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -49,7 +49,13 @@ The three rules (over the representation):
49
49
  It is carried rather than folded in because removing the annotation removes
50
50
  the identity, which is exactly what the redundancy check has to know: a hint
51
51
  inference cannot reach without it is never redundant. */
52
- type idField = {name: string, isList: bool, byTag?: bool}
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
+ }
53
59
 
54
60
  /** One variant arm: its constructor name and the `*Id` fields it carries. */
55
61
  type eventShape = {eventType: string, idFields: array<idField>}
@@ -97,10 +103,13 @@ type derived = {
97
103
  /**
98
104
  The tag key for a `*Id`-shaped field. A plural `*Ids: array<string>` shares the
99
105
  singular producer's key (trailing `s` stripped — `productIds` -> `productId`);
100
- 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.
101
108
  */
102
109
  let tagKeyOf = (f: idField): string =>
103
- 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") {
104
113
  f.name->String.slice(~start=0, ~end=f.name->String.length - 1)
105
114
  } else {
106
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
  /**
@@ -1091,7 +1091,16 @@ let idFieldsOfProperties = (properties: dict<S.t<unknown>>): array<DcbScopeInfer
1091
1091
  // for every field except the ones that need it. Flagged, not folded in: taking
1092
1092
  // the annotation away takes the identity with it, which is what the redundancy
1093
1093
  // check has to be able to tell.
1094
- let identity = (name, fieldSchema, isList) =>
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) =>
1095
1104
  if isIdName(name) {
1096
1105
  Some({DcbScopeInference.name, isList})
1097
1106
  } else if isPartitionTag(fieldSchema) {
@@ -1099,6 +1108,11 @@ let idFieldsOfProperties = (properties: dict<S.t<unknown>>): array<DcbScopeInfer
1099
1108
  } else {
1100
1109
  None
1101
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
+ }
1102
1116
  properties
1103
1117
  ->Dict.toArray
1104
1118
  ->Array.flatMap(((name, fieldSchema)) => {
@@ -1148,7 +1162,8 @@ let eventShapesOfSchema = (schema: S.t<'a>): array<DcbScopeInference.eventShape>
1148
1162
  // --- Partition tag derivation ---
1149
1163
 
1150
1164
  /**
1151
- 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.
1152
1167
  */
1153
1168
  let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
1154
1169
  switch schema->toUnknownSchema {
@@ -1160,7 +1175,7 @@ let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
1160
1175
  ->Dict.toArray
1161
1176
  ->Array.filterMap(((fieldName, fieldSchema)) =>
1162
1177
  if isPartitionTag(fieldSchema) {
1163
- Some(fieldName)
1178
+ Some(resolveTagKey(fieldName, fieldSchema))
1164
1179
  } else {
1165
1180
  None
1166
1181
  }
@@ -1177,7 +1192,7 @@ let extractPartitionTagFields = (schema: S.t<'event>): array<string> => {
1177
1192
  ->Dict.toArray
1178
1193
  ->Array.filterMap(((fieldName, fieldSchema)) =>
1179
1194
  if isPartitionTag(fieldSchema) {
1180
- Some(fieldName)
1195
+ Some(resolveTagKey(fieldName, fieldSchema))
1181
1196
  } else {
1182
1197
  None
1183
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,8 +725,25 @@ 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
+ };
706
738
  let identity = (name, fieldSchema, isList) => {
707
- if (isIdName(name)) {
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)) {
708
747
  return {
709
748
  name: name,
710
749
  isList: isList
@@ -773,16 +812,18 @@ function extractPartitionTagFields(schema) {
773
812
  switch (schema.type) {
774
813
  case "object" :
775
814
  return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
776
- if (Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbPartitionTagId))) {
777
- return param[0];
815
+ let fieldSchema = param[1];
816
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbPartitionTagId))) {
817
+ return resolveTagKey(param[0], fieldSchema);
778
818
  }
779
819
  });
780
820
  case "anyOf" :
781
821
  let allFields = schema.anyOf.flatMap(variantSchema => {
782
822
  if (variantSchema.type === "object") {
783
823
  return Stdlib_Array.filterMap(Object.entries(variantSchema.properties), param => {
784
- if (Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbPartitionTagId))) {
785
- return param[0];
824
+ let fieldSchema = param[1];
825
+ if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbPartitionTagId))) {
826
+ return resolveTagKey(param[0], fieldSchema);
786
827
  }
787
828
  });
788
829
  } else {
@@ -1043,6 +1084,10 @@ export {
1043
1084
  dcbCrossPartitionId,
1044
1085
  dcbCompositePartitionMemberId,
1045
1086
  dcbTagKeyOverrideId,
1087
+ mark,
1088
+ markForKey,
1089
+ markPartition,
1090
+ markCrossPartition,
1046
1091
  string,
1047
1092
  stringForKey,
1048
1093
  int,
@@ -0,0 +1,110 @@
1
+ /**
2
+ Where a plugin's ids and their types disagree. Needs the whole plugin, not one
3
+ file: whether `orderId` names an identity depends on whether any field is typed
4
+ `OrderId.t`, which is also what makes adoption opt-in per plugin — a plugin that
5
+ types nothing is never reported.
6
+
7
+ - **A field named for one identity and typed as another** (`orderId: CustomerId.t`):
8
+ the mix-up the types exist to catch. A role name that is no identity of its own
9
+ (`sellerId: CustomerId.t`) is fine.
10
+ - **An untyped `*Id` whose key has an identity** (`productId: string` beside a
11
+ `ProductId.t`): the half-migrated state, where the compiler guards some uses and
12
+ not others.
13
+ */
14
+ type finding = {sliceName: string, message: string}
15
+
16
+ // The identity key a `*Id` / `*Ids` name gives by convention.
17
+ let nameKey = (name: string): option<string> =>
18
+ if name->String.endsWith("Ids") {
19
+ Some(name->String.slice(~start=0, ~end=name->String.length - 1))
20
+ } else if name->String.length > 2 && name->String.endsWith("Id") {
21
+ Some(name)
22
+ } else {
23
+ None
24
+ }
25
+
26
+ let fieldsOf = (schema: S.t<unknown>): array<(string, S.t<unknown>)> => {
27
+ let ofObject = (v: S.t<unknown>) =>
28
+ switch v {
29
+ | Object({properties}) => properties->Dict.toArray->Array.filter(((n, _)) => n != "TAG")
30
+ | _ => []
31
+ }
32
+ switch schema {
33
+ | AnyOf({anyOf}) => anyOf->Array.flatMap(ofObject)
34
+ | other => ofObject(other)
35
+ }
36
+ }
37
+
38
+ let slicesFields = (slice: DcbTag.sliceSchemas) =>
39
+ [slice.commandSchema, slice.consumedEventSchema, slice.eventSchema]->Array.flatMap(fieldsOf)
40
+
41
+ let check = (slices: array<DcbTag.sliceSchemas>): array<finding> => {
42
+ let declared: Set.t<string> = Set.make()
43
+ slices->Array.forEach(slice =>
44
+ slice
45
+ ->slicesFields
46
+ ->Array.forEach(((_, schema)) =>
47
+ Semantic.fieldIdentityKey(schema)->Option.forEach(k => declared->Set.add(k))
48
+ )
49
+ )
50
+ let seen: Set.t<string> = Set.make()
51
+ let findings = []
52
+ slices->Array.forEach(slice =>
53
+ slice
54
+ ->slicesFields
55
+ ->Array.forEach(((name, schema)) => {
56
+ let message = switch (Semantic.fieldIdentityKey(schema), nameKey(name)) {
57
+ | (Some(key), Some(named)) if named != key && declared->Set.has(named) =>
58
+ Some(
59
+ `${name} is typed as a ${key}, but ${named} is an identity of its own in this plugin. Type it as ${named}'s identity, or rename the field.`,
60
+ )
61
+ | (None, Some(named)) if declared->Set.has(named) =>
62
+ Some(
63
+ `${name} is a plain string, but ${named} has an identity in this plugin. Type the field with it, so the compiler guards every use.`,
64
+ )
65
+ | _ => None
66
+ }
67
+ message->Option.forEach(
68
+ message => {
69
+ let id = slice.name ++ "\n" ++ message
70
+ if !(seen->Set.has(id)) {
71
+ seen->Set.add(id)
72
+ findings->Array.push({sliceName: slice.name, message})
73
+ }
74
+ },
75
+ )
76
+ })
77
+ )
78
+ findings
79
+ }
80
+
81
+ /**
82
+ A slice partitioned by an identity another chapter declares: `categoryId`
83
+ partitioning a slice under `Product/`. The chapter is the default home of the
84
+ identity its slices decide about, so the two disagreeing usually means the slice
85
+ or the identity is in the wrong folder. `identityChapters` maps a key to the
86
+ chapter whose folder declares it; an identity declared in another package has
87
+ none and is not checked.
88
+ */
89
+ let checkChapters = (
90
+ ~identityChapters: dict<string>,
91
+ ~partitionBySlice: dict<string>,
92
+ slices: array<DcbTag.sliceSchemas>,
93
+ ): array<finding> =>
94
+ slices->Array.filterMap(slice =>
95
+ switch (
96
+ partitionBySlice->Dict.get(slice.name),
97
+ slice.moduleUrl->Option.flatMap(DcbTag.chapterOfModuleUrl),
98
+ ) {
99
+ | (Some(key), Some(chapter)) =>
100
+ switch identityChapters->Dict.get(key) {
101
+ | Some(home) if home != chapter =>
102
+ Some({
103
+ sliceName: slice.name,
104
+ message: `is partitioned by ${key}, which ${home}/ declares, but sits in ${chapter}/. Move the slice or the identity so they share a chapter.`,
105
+ })
106
+ | _ => None
107
+ }
108
+ | _ => None
109
+ }
110
+ )
@@ -0,0 +1,103 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as DcbTag$Reventless from "./DcbTag.res.mjs";
6
+ import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
7
+
8
+ function nameKey(name) {
9
+ if (name.endsWith("Ids")) {
10
+ return name.slice(0, name.length - 1 | 0);
11
+ } else if (name.length > 2 && name.endsWith("Id")) {
12
+ return name;
13
+ } else {
14
+ return;
15
+ }
16
+ }
17
+
18
+ function fieldsOf(schema) {
19
+ let ofObject = v => {
20
+ if (v.type === "object") {
21
+ return Object.entries(v.properties).filter(param => param[0] !== "TAG");
22
+ } else {
23
+ return [];
24
+ }
25
+ };
26
+ if (schema.type === "anyOf") {
27
+ return schema.anyOf.flatMap(ofObject);
28
+ } else {
29
+ return ofObject(schema);
30
+ }
31
+ }
32
+
33
+ function slicesFields(slice) {
34
+ return [
35
+ slice.commandSchema,
36
+ slice.consumedEventSchema,
37
+ slice.eventSchema
38
+ ].flatMap(fieldsOf);
39
+ }
40
+
41
+ function check(slices) {
42
+ let declared = new Set();
43
+ slices.forEach(slice => {
44
+ slicesFields(slice).forEach(param => Stdlib_Option.forEach(Semantic$Reventless.fieldIdentityKey(param[1]), k => {
45
+ declared.add(k);
46
+ }));
47
+ });
48
+ let seen = new Set();
49
+ let findings = [];
50
+ slices.forEach(slice => {
51
+ slicesFields(slice).forEach(param => {
52
+ let name = param[0];
53
+ let match = Semantic$Reventless.fieldIdentityKey(param[1]);
54
+ let match$1 = nameKey(name);
55
+ let message = match !== undefined ? (
56
+ match$1 !== undefined && match$1 !== match && declared.has(match$1) ? name + ` is typed as a ` + match + `, but ` + match$1 + ` is an identity of its own in this plugin. Type it as ` + match$1 + `'s identity, or rename the field.` : undefined
57
+ ) : (
58
+ match$1 !== undefined && declared.has(match$1) ? name + ` is a plain string, but ` + match$1 + ` has an identity in this plugin. Type the field with it, so the compiler guards every use.` : undefined
59
+ );
60
+ Stdlib_Option.forEach(message, message => {
61
+ let id = slice.name + "\n" + message;
62
+ if (!seen.has(id)) {
63
+ seen.add(id);
64
+ findings.push({
65
+ sliceName: slice.name,
66
+ message: message
67
+ });
68
+ return;
69
+ }
70
+ });
71
+ });
72
+ });
73
+ return findings;
74
+ }
75
+
76
+ function checkChapters(identityChapters, partitionBySlice, slices) {
77
+ return Stdlib_Array.filterMap(slices, slice => {
78
+ let match = partitionBySlice[slice.name];
79
+ let match$1 = Stdlib_Option.flatMap(slice.moduleUrl, DcbTag$Reventless.chapterOfModuleUrl);
80
+ if (match === undefined) {
81
+ return;
82
+ }
83
+ if (match$1 === undefined) {
84
+ return;
85
+ }
86
+ let home = identityChapters[match];
87
+ if (home !== undefined && home !== match$1) {
88
+ return {
89
+ sliceName: slice.name,
90
+ message: `is partitioned by ` + match + `, which ` + home + `/ declares, but sits in ` + match$1 + `/. Move the slice or the identity so they share a chapter.`
91
+ };
92
+ }
93
+ });
94
+ }
95
+
96
+ export {
97
+ nameKey,
98
+ fieldsOf,
99
+ slicesFields,
100
+ check,
101
+ checkChapters,
102
+ }
103
+ /* DcbTag-Reventless Not a pure module */
@@ -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,
@@ -42,17 +42,15 @@ let decide = (state, command) => switch command {
42
42
  ```
43
43
  */
44
44
  /**
45
- The lean Spec for a StateChangeSlice — types, identity, schemas. State and
46
- state-evolution functions live in the sibling `Behavior` module type.
45
+ The lean Spec for a StateChangeSlice — types and schemas. State and
46
+ state-evolution functions live in the sibling `Behavior` module type. A slice has
47
+ no identity of its own: it decides about the one its partition field carries.
47
48
  */
48
49
  module type Spec = {
49
50
  /** Logical name of this slice (used as a command topic prefix). */
50
51
  let name: string
51
52
  let moduleUrl: string
52
53
 
53
- /** Identity type — always `Id.String` for DCB slices. */
54
- module Id: Id.T
55
-
56
54
  /**
57
55
  Events this slice reads to build its decision model (in `evolve`).
58
56
  Only needs the fields required for the decision — no tag annotations needed.
@@ -51,6 +51,14 @@ module type Spec = {
51
51
  /** Sury schema for the state type — generated automatically by `@schema`. */
52
52
  let stateSchema: S.t<state>
53
53
 
54
+ /**
55
+ What a row is keyed by. `Id.StringPure` (a plain `string`) unless the view
56
+ declares its identity — `module Key = OrderId` — in which case a projection
57
+ that keys a row by another entity's id does not compile. Auto-injected by
58
+ `@@reventless.spec` in a `StateView/` folder.
59
+ */
60
+ module Key: Id.T
61
+
54
62
  /**
55
63
  Events this view slice projects. Only needs the fields required for the projection —
56
64
  no tag annotations needed. May be payload-less where only existence matters.
@@ -114,7 +122,7 @@ module type Projection = {
114
122
  Receives the event wrapped in a `consumed` envelope (event + `meta` +
115
123
  `recordedAt`); only events declared in `Spec.consumedEvent` — no wildcard needed.
116
124
  */
117
- let project: consumed<Spec.consumedEvent> => array<Projection.action<string, Spec.state>>
125
+ let project: consumed<Spec.consumedEvent> => array<Projection.action<Spec.Key.t, Spec.state>>
118
126
 
119
127
  /** File URL of this Projection module (`import.meta.url`). */
120
128
  let moduleUrl: string
@@ -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,27 @@ 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
+
261
+ /** The identity a field names, on its own value or on an array's elements. */
262
+ let rec fieldIdentityKey = (fieldSchema: S.t<unknown>): option<string> =>
263
+ switch identityKey(fieldSchema) {
264
+ | Some(_) as found => found
265
+ | None =>
266
+ switch fieldSchema->unwrapOptional->Option.getOr(fieldSchema) {
267
+ | Array({additionalItems: Schema(item)}) => fieldIdentityKey(item)
268
+ | _ => None
269
+ }
270
+ }
271
+
245
272
  /** Whether a field's schema carries this specific semantic. */
246
273
  let has = (fieldSchema: S.t<'a>, ~id: string): bool =>
247
274
  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,45 @@ 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
+
228
+ function fieldIdentityKey(_fieldSchema) {
229
+ while (true) {
230
+ let fieldSchema = _fieldSchema;
231
+ let found = identityKey(fieldSchema);
232
+ if (found !== undefined) {
233
+ return found;
234
+ }
235
+ let match = Stdlib_Option.getOr(unwrapOptional(fieldSchema), fieldSchema);
236
+ if (match.type !== "array") {
237
+ return;
238
+ }
239
+ let item = match.additionalItems;
240
+ if (item === "strip" || item === "strict") {
241
+ return;
242
+ }
243
+ _fieldSchema = item;
244
+ continue;
245
+ };
246
+ }
247
+
208
248
  function has(fieldSchema, id) {
209
249
  let s = getFrom(fieldSchema);
210
250
  if (s !== undefined) {
@@ -225,6 +265,8 @@ export {
225
265
  unionVariant,
226
266
  getFrom,
227
267
  get,
268
+ identityKey,
269
+ fieldIdentityKey,
228
270
  has,
229
271
  }
230
272
  /* 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 */
@@ -79,23 +79,60 @@ type action<'id, 'state> =
79
79
  /** No-op. Return this when an event should not affect the read model. */
80
80
  | Ignore
81
81
 
82
+ /**
83
+ The same action keyed by another representation of its ids. `to_` converts the
84
+ ids the action carries; `from` converts back the ids its callbacks receive. The
85
+ storage edge uses it to turn a typed row key into the string a table is keyed by.
86
+ */
87
+ let mapActionId = (action: action<'a, 's>, ~to_: 'a => 'b, ~from: 'b => 'a): action<'b, 's> =>
88
+ switch action {
89
+ | Create(id, state) => Create(to_(id), state)
90
+ | CreateMany(rows) => CreateMany(rows->Array.map(((id, state)) => (to_(id), state)))
91
+ | Update(id, f) => Update(to_(id), f)
92
+ | UpdateMany(ids, f) => UpdateMany(ids->Array.map(to_), (id, state) => f(from(id), state))
93
+ | UpdateWithDefault(id, default, f) => UpdateWithDefault(to_(id), default, f)
94
+ | UpdateManyWithDefault(ids, default, f) =>
95
+ UpdateManyWithDefault(
96
+ ids->Array.map(to_),
97
+ id => default(from(id)),
98
+ (id, state) => f(from(id), state),
99
+ )
100
+ | Set(id, state) => Set(to_(id), state)
101
+ | SetMany(ids, f) => SetMany(ids->Array.map(to_), id => f(from(id)))
102
+ | Delete(id) => Delete(to_(id))
103
+ | DeleteMany(ids) => DeleteMany(ids->Array.map(to_))
104
+ | DeleteIf(id, p) => DeleteIf(to_(id), p)
105
+ | DeleteManyIf(ids, p) => DeleteManyIf(ids->Array.map(to_), (id, state) => p(from(id), state))
106
+ | CreateMultiState(id, states) => CreateMultiState(to_(id), states)
107
+ | UpdateMultiState(id, f) => UpdateMultiState(to_(id), f)
108
+ | UpdateManyMultiStates(ids, f) =>
109
+ UpdateManyMultiStates(ids->Array.map(to_), (id, states) => f(from(id), states))
110
+ | Ignore => Ignore
111
+ }
112
+
82
113
  /**
83
114
  A compiled single-source-to-single-target mapping.
84
115
 
85
116
  Created by `Projection.Mapping.Make(Source, Target, MappingImpl)`.
86
117
  The `project` function receives a full `Message.event'` envelope and returns
87
- one `action` value.
118
+ one `action` value. Both ends are typed: the envelope id is the source's `Id.t`
119
+ and the row key the target's, so a projection that keys a view by another
120
+ entity's id does not compile. A mapping between different ids converts
121
+ explicitly (`Target.Id.makeFromString`), which marks the seam.
88
122
  */
89
123
  module type Mapping = {
90
124
  //module Source: Source
91
125
  //module Target: Target // NOTE: to be destructive substituted
92
126
  module SourceId: Id.T
127
+ type targetId
93
128
  @schema
94
129
  type sourceEvent
95
130
  @schema
96
131
  type targetState
97
132
 
98
- let project: Message.event'<string, sourceEvent> => action<string, targetState>
133
+ let project: Message.event'<SourceId.t, sourceEvent> => action<targetId, targetState>
134
+ let targetIdToString: targetId => string
135
+ let targetIdFromString: string => targetId
99
136
  let sourceEventSchema: S.t<sourceEvent>
100
137
  let sourceName: string
101
138
  let subIdConfig: option<ReadModel.subIdConfig<targetState>>
@@ -116,9 +153,11 @@ module type Mappings = {
116
153
  }
117
154
 
118
155
  module type MappingImpl = {
156
+ type sourceId
157
+ type targetId
119
158
  type sourceEvent
120
159
  type targetState
121
- let project: Message.event'<string, sourceEvent> => action<string, targetState>
160
+ let project: Message.event'<sourceId, sourceEvent> => action<targetId, targetState>
122
161
  }
123
162
 
124
163
  /**
@@ -147,7 +186,9 @@ module Mapping = {
147
186
  Target: Target,
148
187
  MappingImpl: MappingImpl
149
188
  with type sourceEvent := Source.event
150
- and type targetState := Target.state,
189
+ and type targetState := Target.state
190
+ and type sourceId := Source.Id.t
191
+ and type targetId := Target.Id.t,
151
192
  ): (
152
193
  Mapping
153
194
  with type targetState = Target.state
@@ -155,11 +196,14 @@ module Mapping = {
155
196
  and module SourceId = Source.Id
156
197
  ) => {
157
198
  module SourceId = Source.Id
199
+ type targetId = Target.Id.t
158
200
  @schema
159
201
  type sourceEvent = Source.event
160
202
  @schema
161
203
  type targetState = Target.state
162
204
  let project = MappingImpl.project
205
+ let targetIdToString = Target.Id.toString
206
+ let targetIdFromString = Target.Id.makeFromString
163
207
  let sourceName = Source.name
164
208
  let subIdConfig = Target.subIdConfig
165
209
  }
@@ -1,15 +1,127 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
 
4
+ function mapActionId(action, to_, from) {
5
+ if (typeof action !== "object") {
6
+ return "Ignore";
7
+ }
8
+ switch (action.TAG) {
9
+ case "Create" :
10
+ return {
11
+ TAG: "Create",
12
+ _0: to_(action._0),
13
+ _1: action._1
14
+ };
15
+ case "CreateMany" :
16
+ return {
17
+ TAG: "CreateMany",
18
+ _0: action._0.map(param => [
19
+ to_(param[0]),
20
+ param[1]
21
+ ])
22
+ };
23
+ case "Update" :
24
+ return {
25
+ TAG: "Update",
26
+ _0: to_(action._0),
27
+ _1: action._1
28
+ };
29
+ case "UpdateMany" :
30
+ let f = action._1;
31
+ return {
32
+ TAG: "UpdateMany",
33
+ _0: action._0.map(to_),
34
+ _1: (id, state) => f(from(id), state)
35
+ };
36
+ case "UpdateWithDefault" :
37
+ return {
38
+ TAG: "UpdateWithDefault",
39
+ _0: to_(action._0),
40
+ _1: action._1,
41
+ _2: action._2
42
+ };
43
+ case "UpdateManyWithDefault" :
44
+ let f$1 = action._2;
45
+ let $$default = action._1;
46
+ return {
47
+ TAG: "UpdateManyWithDefault",
48
+ _0: action._0.map(to_),
49
+ _1: id => $$default(from(id)),
50
+ _2: (id, state) => f$1(from(id), state)
51
+ };
52
+ case "Set" :
53
+ return {
54
+ TAG: "Set",
55
+ _0: to_(action._0),
56
+ _1: action._1
57
+ };
58
+ case "SetMany" :
59
+ let f$2 = action._1;
60
+ return {
61
+ TAG: "SetMany",
62
+ _0: action._0.map(to_),
63
+ _1: id => f$2(from(id))
64
+ };
65
+ case "Delete" :
66
+ return {
67
+ TAG: "Delete",
68
+ _0: to_(action._0)
69
+ };
70
+ case "DeleteMany" :
71
+ return {
72
+ TAG: "DeleteMany",
73
+ _0: action._0.map(to_)
74
+ };
75
+ case "DeleteIf" :
76
+ return {
77
+ TAG: "DeleteIf",
78
+ _0: to_(action._0),
79
+ _1: action._1
80
+ };
81
+ case "DeleteManyIf" :
82
+ let p = action._1;
83
+ return {
84
+ TAG: "DeleteManyIf",
85
+ _0: action._0.map(to_),
86
+ _1: (id, state) => p(from(id), state)
87
+ };
88
+ case "CreateMultiState" :
89
+ return {
90
+ TAG: "CreateMultiState",
91
+ _0: to_(action._0),
92
+ _1: action._1
93
+ };
94
+ case "UpdateMultiState" :
95
+ return {
96
+ TAG: "UpdateMultiState",
97
+ _0: to_(action._0),
98
+ _1: action._1
99
+ };
100
+ case "UpdateManyMultiStates" :
101
+ let f$3 = action._1;
102
+ return {
103
+ TAG: "UpdateManyMultiStates",
104
+ _0: action._0.map(to_),
105
+ _1: (id, states) => f$3(from(id), states)
106
+ };
107
+ }
108
+ }
109
+
4
110
  function Make(Source) {
5
- return Target => (MappingImpl => ({
6
- SourceId: Source.Id,
7
- project: MappingImpl.project,
8
- sourceEventSchema: Source.eventSchema,
9
- sourceName: Source.name,
10
- subIdConfig: Target.subIdConfig,
11
- targetStateSchema: Target.stateSchema
12
- }));
111
+ return Target => (MappingImpl => {
112
+ let targetIdToString = Target.Id.toString;
113
+ let targetIdFromString = Target.Id.makeFromString;
114
+ return {
115
+ SourceId: Source.Id,
116
+ project: MappingImpl.project,
117
+ targetIdToString: targetIdToString,
118
+ targetIdFromString: targetIdFromString,
119
+ sourceEventSchema: Source.eventSchema,
120
+ sourceName: Source.name,
121
+ subIdConfig: Target.subIdConfig,
122
+ targetStateSchema: Target.stateSchema
123
+ };
124
+ });
13
125
  }
14
126
 
15
127
  let Mapping = {
@@ -37,6 +149,7 @@ let DcbSource = {
37
149
  };
38
150
 
39
151
  export {
152
+ mapActionId,
40
153
  Mapping,
41
154
  Mappings,
42
155
  DcbSource,