@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 +24 -0
- package/package.json +1 -1
- package/src/components/DcbScopeInference.res +12 -3
- package/src/components/DcbScopeInference.res.mjs +3 -1
- package/src/components/DcbTag.res +64 -49
- package/src/components/DcbTag.res.mjs +72 -27
- package/src/components/IdentityCheck.res +110 -0
- package/src/components/IdentityCheck.res.mjs +103 -0
- package/src/components/Reference.res +26 -0
- package/src/components/Reference.res.mjs +31 -0
- package/src/components/StateChangeSlice.res +3 -5
- package/src/components/StateViewSlice.res +9 -1
- package/src/semantic/Semantic.res +28 -1
- package/src/semantic/Semantic.res.mjs +43 -1
- package/src/types/Id.res +34 -4
- package/src/types/Id.res.mjs +17 -0
- package/src/types/Projection.res +48 -4
- package/src/types/Projection.res.mjs +121 -8
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
|
@@ -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 = {
|
|
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.
|
|
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 (
|
|
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->
|
|
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->
|
|
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->
|
|
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)
|
|
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
|
|
365
|
-
inner element schema; falls back to the field name
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
59
|
+
let partition = markPartition(Sury.string);
|
|
44
60
|
|
|
45
|
-
let crossPartition =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
return param[0];
|
|
635
|
-
}
|
|
636
|
-
}).toSorted(Primitive_string.compare);
|
|
660
|
+
keys = ofProperties(schema.properties);
|
|
661
|
+
break;
|
|
637
662
|
case "anyOf" :
|
|
638
|
-
|
|
663
|
+
keys = schema.anyOf.flatMap(variantSchema => {
|
|
639
664
|
if (variantSchema.type === "object") {
|
|
640
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
777
|
-
|
|
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
|
-
|
|
785
|
-
|
|
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
|
|
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<
|
|
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`
|
|
6
|
-
|
|
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,
|
|
66
|
-
|
|
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
|
+
}
|
package/src/types/Id.res.mjs
CHANGED
|
@@ -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 */
|
package/src/types/Projection.res
CHANGED
|
@@ -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'<
|
|
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'<
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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,
|