@reventlessdev/reventless-spec 3.0.0-alpha.61
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 +625 -0
- package/LICENSE +202 -0
- package/README.md +114 -0
- package/package.json +34 -0
- package/rescript.json +25 -0
- package/run-generator.mjs +2 -0
- package/src/AnsiStyle.res +40 -0
- package/src/AnsiStyle.res.mjs +54 -0
- package/src/LogPrefix.res +168 -0
- package/src/LogPrefix.res.mjs +148 -0
- package/src/PackageVersion.res +73 -0
- package/src/PackageVersion.res.mjs +81 -0
- package/src/components/Aggregate.res +64 -0
- package/src/components/Aggregate.res.mjs +2 -0
- package/src/components/AutomationSlice.res +279 -0
- package/src/components/AutomationSlice.res.mjs +30 -0
- package/src/components/Counter.res +24 -0
- package/src/components/Counter.res.mjs +2 -0
- package/src/components/DcbDecode.res +128 -0
- package/src/components/DcbDecode.res.mjs +117 -0
- package/src/components/DcbTag.res +1256 -0
- package/src/components/DcbTag.res.mjs +869 -0
- package/src/components/DcbValidation.res +362 -0
- package/src/components/DcbValidation.res.mjs +360 -0
- package/src/components/DisplayName.res +40 -0
- package/src/components/DisplayName.res.mjs +26 -0
- package/src/components/ExtensionPoint.res +27 -0
- package/src/components/ExtensionPoint.res.mjs +2 -0
- package/src/components/InboundTranslationSlice.res +79 -0
- package/src/components/InboundTranslationSlice.res.mjs +2 -0
- package/src/components/OutboundTranslationSlice.res +108 -0
- package/src/components/OutboundTranslationSlice.res.mjs +2 -0
- package/src/components/Plugin.res +373 -0
- package/src/components/Plugin.res.mjs +249 -0
- package/src/components/ReadModel.res +199 -0
- package/src/components/ReadModel.res.mjs +18 -0
- package/src/components/Reference.res +51 -0
- package/src/components/Reference.res.mjs +37 -0
- package/src/components/StateAnnotations.res +65 -0
- package/src/components/StateAnnotations.res.mjs +15 -0
- package/src/components/StateChangeSlice.res +131 -0
- package/src/components/StateChangeSlice.res.mjs +2 -0
- package/src/components/StateViewSlice.res +99 -0
- package/src/components/StateViewSlice.res.mjs +2 -0
- package/src/components/Task.res +62 -0
- package/src/components/Task.res.mjs +2 -0
- package/src/generator/Codegen.res +703 -0
- package/src/generator/Codegen.res.mjs +485 -0
- package/src/generator/Config.res +85 -0
- package/src/generator/Config.res.mjs +78 -0
- package/src/generator/Discovery.res +204 -0
- package/src/generator/Discovery.res.mjs +213 -0
- package/src/generator/Generator_Node.res +49 -0
- package/src/generator/Generator_Node.res.mjs +18 -0
- package/src/generator/Pairing.res +386 -0
- package/src/generator/Pairing.res.mjs +353 -0
- package/src/generator/PluginGenerator.res +53 -0
- package/src/generator/PluginGenerator.res.mjs +70 -0
- package/src/types/Authorization.res +23 -0
- package/src/types/Authorization.res.mjs +33 -0
- package/src/types/Behavior.res +78 -0
- package/src/types/Behavior.res.mjs +2 -0
- package/src/types/EventMapping.res +100 -0
- package/src/types/EventMapping.res.mjs +15 -0
- package/src/types/Handler.res +30 -0
- package/src/types/Handler.res.mjs +2 -0
- package/src/types/Id.res +75 -0
- package/src/types/Id.res.mjs +37 -0
- package/src/types/Identity.res +46 -0
- package/src/types/Identity.res.mjs +51 -0
- package/src/types/Message.res +197 -0
- package/src/types/Message.res.mjs +108 -0
- package/src/types/Projection.res +220 -0
- package/src/types/Projection.res.mjs +44 -0
- package/src/types/QueryEngine.res +123 -0
- package/src/types/QueryEngine.res.mjs +12 -0
- package/src/types/ReadConsistency.res +38 -0
- package/src/types/ReadConsistency.res.mjs +29 -0
- package/src/types/Schedule.res +65 -0
- package/src/types/Schedule.res.mjs +68 -0
- package/src/types/SideEffect.res +45 -0
- package/src/types/SideEffect.res.mjs +2 -0
- package/src/types/StoredEvent.res +46 -0
- package/src/types/StoredEvent.res.mjs +32 -0
- package/src/types/Visibility.res +24 -0
- package/src/types/Visibility.res.mjs +25 -0
|
@@ -0,0 +1,360 @@
|
|
|
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 Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
6
|
+
import * as DcbTag$Reventless from "./DcbTag.res.mjs";
|
|
7
|
+
|
|
8
|
+
function extractVariantInfo(variantSchema) {
|
|
9
|
+
if (variantSchema.type !== "object") {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let properties = variantSchema.properties;
|
|
13
|
+
let tagName = Stdlib_Option.flatMap(variantSchema.items.find(item => item.location === "TAG"), item => {
|
|
14
|
+
let match = item.schema;
|
|
15
|
+
if (match.type !== "string") {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let $$const = match.const;
|
|
19
|
+
if ($$const !== undefined) {
|
|
20
|
+
return $$const;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
return Stdlib_Option.map(tagName, tagName => {
|
|
24
|
+
let taggedFields = Stdlib_Array.filterMap(Object.entries(properties), param => {
|
|
25
|
+
if (DcbTag$Reventless.isTagged(param[1])) {
|
|
26
|
+
return param[0];
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
tagName: tagName,
|
|
31
|
+
fields: properties,
|
|
32
|
+
taggedFields: taggedFields
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractAllVariants(schema) {
|
|
38
|
+
switch (schema.type) {
|
|
39
|
+
case "string" :
|
|
40
|
+
let name = schema.const;
|
|
41
|
+
if (name !== undefined) {
|
|
42
|
+
return [{
|
|
43
|
+
tagName: name,
|
|
44
|
+
fields: {},
|
|
45
|
+
taggedFields: []
|
|
46
|
+
}];
|
|
47
|
+
} else {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
case "object" :
|
|
51
|
+
let info = extractVariantInfo(schema);
|
|
52
|
+
if (info !== undefined) {
|
|
53
|
+
return [info];
|
|
54
|
+
} else {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
case "union" :
|
|
58
|
+
return Stdlib_Array.filterMap(schema.anyOf, extractVariantInfo);
|
|
59
|
+
default:
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPayloadLess(info) {
|
|
65
|
+
return Object.entries(info.fields).length === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function schemasAreCompatible(_a, _b) {
|
|
69
|
+
while (true) {
|
|
70
|
+
let b = _b;
|
|
71
|
+
let a = _a;
|
|
72
|
+
switch (a.type) {
|
|
73
|
+
case "string" :
|
|
74
|
+
return b.type === "string";
|
|
75
|
+
case "number" :
|
|
76
|
+
return b.type === "number";
|
|
77
|
+
case "bigint" :
|
|
78
|
+
return b.type === "bigint";
|
|
79
|
+
case "boolean" :
|
|
80
|
+
return b.type === "boolean";
|
|
81
|
+
case "array" :
|
|
82
|
+
let aItem = a.additionalItems;
|
|
83
|
+
if (aItem === "strip" || aItem === "strict") {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (b.type !== "array") {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
let bItem = b.additionalItems;
|
|
90
|
+
if (bItem === "strip" || bItem === "strict") {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
_b = bItem;
|
|
94
|
+
_a = aItem;
|
|
95
|
+
continue;
|
|
96
|
+
case "object" :
|
|
97
|
+
return b.type === "object";
|
|
98
|
+
case "union" :
|
|
99
|
+
return b.type === "union";
|
|
100
|
+
default:
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function schemaTypeName(schema) {
|
|
107
|
+
switch (schema.type) {
|
|
108
|
+
case "never" :
|
|
109
|
+
return "never";
|
|
110
|
+
case "string" :
|
|
111
|
+
return "string";
|
|
112
|
+
case "number" :
|
|
113
|
+
return "float";
|
|
114
|
+
case "bigint" :
|
|
115
|
+
return "bigint";
|
|
116
|
+
case "boolean" :
|
|
117
|
+
return "bool";
|
|
118
|
+
case "array" :
|
|
119
|
+
return "array";
|
|
120
|
+
case "object" :
|
|
121
|
+
return "object";
|
|
122
|
+
case "union" :
|
|
123
|
+
return "union";
|
|
124
|
+
case "ref" :
|
|
125
|
+
return "ref";
|
|
126
|
+
default:
|
|
127
|
+
return "unknown";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function dedupeKeys(keys) {
|
|
132
|
+
let seen = new Set();
|
|
133
|
+
return keys.filter(k => {
|
|
134
|
+
if (seen.has(k)) {
|
|
135
|
+
return false;
|
|
136
|
+
} else {
|
|
137
|
+
seen.add(k);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validateCompositeReads(slices, producedTagKeys) {
|
|
144
|
+
let warnings = [];
|
|
145
|
+
slices.forEach(param => {
|
|
146
|
+
let commandSchema = param[1];
|
|
147
|
+
let sliceName = param[0];
|
|
148
|
+
if (DcbTag$Reventless.hasTaggedArrayFields(commandSchema)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
let commandTagKeysByVariant = DcbTag$Reventless.extractTagKeysByEventType(commandSchema);
|
|
152
|
+
let consumedTypes = DcbTag$Reventless.extractVariantNames(param[2]);
|
|
153
|
+
Object.values(commandTagKeysByVariant).forEach(cmdKeys => {
|
|
154
|
+
let querySet = dedupeKeys(cmdKeys);
|
|
155
|
+
if (querySet.length >= 2) {
|
|
156
|
+
consumedTypes.forEach(consumedType => {
|
|
157
|
+
let producedKeys = producedTagKeys[consumedType];
|
|
158
|
+
if (producedKeys === undefined) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
let producedSet = dedupeKeys(producedKeys);
|
|
162
|
+
let isSuperset = querySet.every(k => producedSet.includes(k));
|
|
163
|
+
if (!(isSuperset && producedSet.length > querySet.length)) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
let extra = producedSet.filter(k => !querySet.includes(k));
|
|
167
|
+
warnings.push({
|
|
168
|
+
sliceName: sliceName,
|
|
169
|
+
message: `composite read on tags [` + querySet.join(", ") + `] will silently miss '` + consumedType + `', which also carries [` + extra.join(", ") + `] — the tag_composite key includes ALL of an event's tags, so a composite query matches only events tagged exactly [` + querySet.join(", ") + `]. Align the event's tag set with the query, or read '` + consumedType + `' via a single-tag clause.`
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
return warnings;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validateProducedAndConsumed(produced, consumed) {
|
|
180
|
+
let errors = [];
|
|
181
|
+
let producerMap = {};
|
|
182
|
+
produced.forEach(param => {
|
|
183
|
+
let sliceName = param[0];
|
|
184
|
+
let variants = extractAllVariants(param[1]);
|
|
185
|
+
variants.forEach(variant => {
|
|
186
|
+
let existing = Stdlib_Option.getOr(producerMap[variant.tagName], []);
|
|
187
|
+
producerMap[variant.tagName] = existing.concat([{
|
|
188
|
+
sliceName: sliceName,
|
|
189
|
+
variant: variant
|
|
190
|
+
}]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
Object.entries(producerMap).forEach(param => {
|
|
194
|
+
let entries = param[1];
|
|
195
|
+
if (entries.length <= 1) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
let tagName = param[0];
|
|
199
|
+
let first = entries[0];
|
|
200
|
+
let firstFields = Object.keys(first.variant.fields).toSorted(Primitive_string.compare);
|
|
201
|
+
let firstTagged = first.variant.taggedFields.toSorted(Primitive_string.compare);
|
|
202
|
+
entries.forEach((entry, idx) => {
|
|
203
|
+
if (idx <= 0) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let entryFields = Object.keys(entry.variant.fields).toSorted(Primitive_string.compare);
|
|
207
|
+
let entryTagged = entry.variant.taggedFields.toSorted(Primitive_string.compare);
|
|
208
|
+
if (firstFields.length !== entryFields.length || firstFields.some(f => !entryFields.includes(f))) {
|
|
209
|
+
let missingInEntry = firstFields.filter(f => !entryFields.includes(f));
|
|
210
|
+
let extraInEntry = entryFields.filter(f => !firstFields.includes(f));
|
|
211
|
+
let parts = [];
|
|
212
|
+
if (missingInEntry.length !== 0) {
|
|
213
|
+
parts.push(`'` + first.sliceName + `' has fields [` + missingInEntry.join(", ") + `] which '` + entry.sliceName + `' is missing`);
|
|
214
|
+
}
|
|
215
|
+
if (extraInEntry.length !== 0) {
|
|
216
|
+
parts.push(`'` + entry.sliceName + `' has fields [` + extraInEntry.join(", ") + `] which '` + first.sliceName + `' is missing`);
|
|
217
|
+
}
|
|
218
|
+
errors.push({
|
|
219
|
+
sliceName: entry.sliceName,
|
|
220
|
+
message: `Producers '` + first.sliceName + `' and '` + entry.sliceName + `' both produce '` + tagName + `' but with different fields: ` + parts.join("; ")
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
firstFields.forEach(fieldName => {
|
|
224
|
+
let firstSchema = first.variant.fields[fieldName];
|
|
225
|
+
let entrySchema = entry.variant.fields[fieldName];
|
|
226
|
+
if (!schemasAreCompatible(firstSchema, entrySchema)) {
|
|
227
|
+
errors.push({
|
|
228
|
+
sliceName: entry.sliceName,
|
|
229
|
+
message: `Producers '` + first.sliceName + `' and '` + entry.sliceName + `' both produce '` + tagName + `' but field '` + fieldName + `' has type '` + schemaTypeName(firstSchema) + `' in '` + first.sliceName + `' and '` + schemaTypeName(entrySchema) + `' in '` + entry.sliceName + `'`
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (!(firstTagged.length !== entryTagged.length || firstTagged.some(f => !entryTagged.includes(f)))) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
let untaggedInEntry = firstTagged.filter(f => !entryTagged.includes(f));
|
|
239
|
+
let untaggedInFirst = entryTagged.filter(f => !firstTagged.includes(f));
|
|
240
|
+
let parts$1 = [];
|
|
241
|
+
if (untaggedInEntry.length !== 0) {
|
|
242
|
+
parts$1.push(`[` + untaggedInEntry.join(", ") + `] tagged in '` + first.sliceName + `' but not in '` + entry.sliceName + `'`);
|
|
243
|
+
}
|
|
244
|
+
if (untaggedInFirst.length !== 0) {
|
|
245
|
+
parts$1.push(`[` + untaggedInFirst.join(", ") + `] tagged in '` + entry.sliceName + `' but not in '` + first.sliceName + `'`);
|
|
246
|
+
}
|
|
247
|
+
errors.push({
|
|
248
|
+
sliceName: entry.sliceName,
|
|
249
|
+
message: `Producers '` + first.sliceName + `' and '` + entry.sliceName + `' both produce '` + tagName + `' but tag annotations differ: ` + parts$1.join("; ")
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
consumed.forEach(param => {
|
|
254
|
+
let sliceName = param[0];
|
|
255
|
+
let variants = extractAllVariants(param[1]);
|
|
256
|
+
variants.forEach(variant => {
|
|
257
|
+
let producers = producerMap[variant.tagName];
|
|
258
|
+
if (producers !== undefined) {
|
|
259
|
+
let producer = producers[0];
|
|
260
|
+
if (!isPayloadLess(variant)) {
|
|
261
|
+
Object.entries(variant.fields).forEach(param => {
|
|
262
|
+
let consumedFieldSchema = param[1];
|
|
263
|
+
let fieldName = param[0];
|
|
264
|
+
let producedFieldSchema = producer.variant.fields[fieldName];
|
|
265
|
+
if (producedFieldSchema !== undefined) {
|
|
266
|
+
if (!schemasAreCompatible(consumedFieldSchema, producedFieldSchema)) {
|
|
267
|
+
errors.push({
|
|
268
|
+
sliceName: sliceName,
|
|
269
|
+
message: `Slice '` + sliceName + `' consumes '` + variant.tagName + `.` + fieldName + `' as ` + schemaTypeName(consumedFieldSchema) + ` but producer declares it as ` + schemaTypeName(producedFieldSchema)
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
} else {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
errors.push({
|
|
277
|
+
sliceName: sliceName,
|
|
278
|
+
message: `Slice '` + sliceName + `' consumes '` + variant.tagName + `.` + fieldName + `' (` + schemaTypeName(consumedFieldSchema) + `) but produced '` + variant.tagName + `' has no field '` + fieldName + `'`
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
} else {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
errors.push({
|
|
289
|
+
sliceName: sliceName,
|
|
290
|
+
message: `Slice '` + sliceName + `' consumes '` + variant.tagName + `' but no slice produces it`
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
if (errors.length !== 0) {
|
|
295
|
+
return {
|
|
296
|
+
TAG: "Error",
|
|
297
|
+
_0: errors
|
|
298
|
+
};
|
|
299
|
+
} else {
|
|
300
|
+
return {
|
|
301
|
+
TAG: "Ok",
|
|
302
|
+
_0: undefined
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function validateCrossPartitionScope(producers) {
|
|
308
|
+
let perProducer = producers.map(param => {
|
|
309
|
+
let schema = param[1];
|
|
310
|
+
let cpSet = new Set();
|
|
311
|
+
DcbTag$Reventless.extractCrossPartitionTagKeys(schema).forEach(k => {
|
|
312
|
+
cpSet.add(k);
|
|
313
|
+
});
|
|
314
|
+
let carried = new Set();
|
|
315
|
+
Object.values(DcbTag$Reventless.extractTagKeysByEventType(schema)).forEach(ks => {
|
|
316
|
+
ks.forEach(k => {
|
|
317
|
+
carried.add(k);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
return [
|
|
321
|
+
param[0],
|
|
322
|
+
cpSet,
|
|
323
|
+
carried
|
|
324
|
+
];
|
|
325
|
+
});
|
|
326
|
+
let globalCp = new Set();
|
|
327
|
+
perProducer.forEach(param => {
|
|
328
|
+
Array.from(param[1].values()).forEach(k => {
|
|
329
|
+
globalCp.add(k);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
let warnings = [];
|
|
333
|
+
perProducer.forEach(param => {
|
|
334
|
+
let cpSet = param[1];
|
|
335
|
+
let name = param[0];
|
|
336
|
+
Array.from(param[2].values()).toSorted(Primitive_string.compare).forEach(k => {
|
|
337
|
+
if (globalCp.has(k) && !cpSet.has(k)) {
|
|
338
|
+
warnings.push({
|
|
339
|
+
sliceName: name,
|
|
340
|
+
message: `tag '` + k + `' is declared @crossPartition by another producer but is partition-scoped here — DCB tag scope must agree across every event type that carries the tag (read-scope and fence-scope follow one global per-key flag). Add @crossPartition to '` + k + `' here, or remove it from the other producer.`
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
return warnings;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export {
|
|
350
|
+
extractVariantInfo,
|
|
351
|
+
extractAllVariants,
|
|
352
|
+
isPayloadLess,
|
|
353
|
+
schemasAreCompatible,
|
|
354
|
+
schemaTypeName,
|
|
355
|
+
dedupeKeys,
|
|
356
|
+
validateCompositeReads,
|
|
357
|
+
validateProducedAndConsumed,
|
|
358
|
+
validateCrossPartitionScope,
|
|
359
|
+
}
|
|
360
|
+
/* DcbTag-Reventless Not a pure module */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Spec for a composite display-name derivation.
|
|
3
|
+
|
|
4
|
+
`fields` names the source fields in declaration order; `separator` is inserted
|
|
5
|
+
between successive non-empty parts when `computeLabel` is applied.
|
|
6
|
+
|
|
7
|
+
The ppx attaches this spec to a state schema whenever one or more fields carry
|
|
8
|
+
the `@displayName` attribute. The projection runtime reads the spec at write
|
|
9
|
+
time and composes the label into a synthetic `displayName` field on state.
|
|
10
|
+
*/
|
|
11
|
+
type displayNameSpec = {
|
|
12
|
+
fields: array<string>,
|
|
13
|
+
separator: string,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Sury metadata ID used to attach a `displayNameSpec` to a state schema. */
|
|
17
|
+
let displayNameId: S.Metadata.Id.t<displayNameSpec> =
|
|
18
|
+
S.Metadata.Id.make(~namespace="reventless", ~name="displayName")
|
|
19
|
+
|
|
20
|
+
/** Returns the spec attached to a state schema, if any. */
|
|
21
|
+
let getSpec = (schema: S.t<unknown>): option<displayNameSpec> =>
|
|
22
|
+
S.Metadata.get(schema, ~id=displayNameId)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
Composes a display-name label from a state dict and a spec.
|
|
26
|
+
|
|
27
|
+
Reads each source field by name from the dict. Entries that are absent,
|
|
28
|
+
`null`, or empty strings are skipped. Remaining string values are joined
|
|
29
|
+
with `spec.separator`. The result never has a leading, trailing, or
|
|
30
|
+
doubled separator.
|
|
31
|
+
*/
|
|
32
|
+
let computeLabel = (spec: displayNameSpec, state: dict<JSON.t>): string =>
|
|
33
|
+
spec.fields
|
|
34
|
+
->Array.filterMap(fieldName =>
|
|
35
|
+
switch state->Dict.get(fieldName) {
|
|
36
|
+
| Some(JSON.String(s)) if s != "" => Some(s)
|
|
37
|
+
| _ => None
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
->Array.join(spec.separator)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
|
|
6
|
+
let displayNameId = S.Metadata.Id.make("reventless", "displayName");
|
|
7
|
+
|
|
8
|
+
function getSpec(schema) {
|
|
9
|
+
return S.Metadata.get(schema, displayNameId);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function computeLabel(spec, state) {
|
|
13
|
+
return Stdlib_Array.filterMap(spec.fields, fieldName => {
|
|
14
|
+
let match = state[fieldName];
|
|
15
|
+
if (typeof match === "string" && match !== "") {
|
|
16
|
+
return match;
|
|
17
|
+
}
|
|
18
|
+
}).join(spec.separator);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
displayNameId,
|
|
23
|
+
getSpec,
|
|
24
|
+
computeLabel,
|
|
25
|
+
}
|
|
26
|
+
/* displayNameId Not a pure module */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Module type for an extension point's identity and schema specification.
|
|
3
|
+
|
|
4
|
+
An extension point is a bidirectional protocol boundary: it exposes a
|
|
5
|
+
command topic for extensions to publish to, and an event topic that
|
|
6
|
+
extensions subscribe to. The `directive` type is used for internal
|
|
7
|
+
routing signals that are not exposed as public events.
|
|
8
|
+
|
|
9
|
+
Note: `module Id = Id.String` is fixed — extension points always use string IDs.
|
|
10
|
+
*/
|
|
11
|
+
module type Spec = {
|
|
12
|
+
module Id = Id.String
|
|
13
|
+
|
|
14
|
+
/** Logical extension point name (used as a topic name prefix). */
|
|
15
|
+
let name: string
|
|
16
|
+
|
|
17
|
+
/** Commands that extensions can send to this extension point. Must carry `@schema`. */
|
|
18
|
+
@schema
|
|
19
|
+
type command
|
|
20
|
+
/** Events that this extension point emits to subscribed extensions. Must carry `@schema`. */
|
|
21
|
+
@schema
|
|
22
|
+
type event
|
|
23
|
+
/** Internal routing directives (not exposed externally). Must carry `@schema`. */
|
|
24
|
+
@schema
|
|
25
|
+
type directive
|
|
26
|
+
}
|
|
27
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Module types for a DCB inbound translation slice.
|
|
3
|
+
|
|
4
|
+
An `InboundTranslationSlice` receives external input (webhooks, API calls) and
|
|
5
|
+
translates it into domain commands via an anti-corruption layer.
|
|
6
|
+
|
|
7
|
+
Unlike outbound slices, inbound slices are triggered externally via
|
|
8
|
+
`operations.receive` rather than by subscribing to domain events.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
External Input -> Anti-Corruption Layer (translate) -> Command
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Plan 02 splits the merged spec into two module types:
|
|
15
|
+
|
|
16
|
+
- `Spec` — types, identity, schemas, target name. (No state — translation is
|
|
17
|
+
a pure function of external input.)
|
|
18
|
+
- `Translation` — the single `translate` function.
|
|
19
|
+
|
|
20
|
+
@example
|
|
21
|
+
```rescript
|
|
22
|
+
// PaymentWebhook.res
|
|
23
|
+
let name = "PaymentWebhook"
|
|
24
|
+
|
|
25
|
+
@schema type externalInput = {paymentId: string, orderId: string, status: string}
|
|
26
|
+
@schema type command = ConfirmPayment({orderId: @s.matches(DcbTag.string) string, paymentId: string})
|
|
27
|
+
|
|
28
|
+
let translate = input => switch input.status {
|
|
29
|
+
| "completed" => Ok([(input.orderId, ConfirmPayment({orderId: input.orderId, paymentId: input.paymentId}))])
|
|
30
|
+
| _ => Error("Unknown payment status: " ++ input.status)
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
The lean Spec for an InboundTranslationSlice — types, identity, schemas.
|
|
37
|
+
*/
|
|
38
|
+
module type Spec = {
|
|
39
|
+
/** Logical name of this inbound translation slice (used as a component prefix). */
|
|
40
|
+
let name: string
|
|
41
|
+
let moduleUrl: string
|
|
42
|
+
|
|
43
|
+
/** The external input type received from the outside world. Must carry `@schema`. */
|
|
44
|
+
@schema
|
|
45
|
+
type externalInput
|
|
46
|
+
|
|
47
|
+
/** The command type produced by the anti-corruption layer. Must carry `@schema`. */
|
|
48
|
+
@schema
|
|
49
|
+
type command
|
|
50
|
+
|
|
51
|
+
/** Name of the aggregate or StateChangeSlice that receives the produced command. */
|
|
52
|
+
let targetName: string
|
|
53
|
+
|
|
54
|
+
/** Authorization rule evaluated at the GraphQL resolver entry before any
|
|
55
|
+
external input is translated. Auto-injected by `@@reventless.spec` and
|
|
56
|
+
on structurally-detected inline spec modules — defaults to
|
|
57
|
+
`AllowAuthenticated`. */
|
|
58
|
+
let commandAuthorization: command => Authorization.permission
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
The Translation — the synchronous translate function.
|
|
63
|
+
*/
|
|
64
|
+
module type Translation = {
|
|
65
|
+
module Spec: Spec
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
Translate: convert external input into domain commands.
|
|
69
|
+
Returns:
|
|
70
|
+
- `Ok([(targetId, cmd), ...])` to publish one or more commands
|
|
71
|
+
- `Ok([])` for idempotent no-ops (nothing to publish)
|
|
72
|
+
- `Error(msg)` to reject the input
|
|
73
|
+
*/
|
|
74
|
+
let translate: Spec.externalInput => result<array<(string, Spec.command)>, string>
|
|
75
|
+
|
|
76
|
+
/** File URL of this Translation module (`import.meta.url`). */
|
|
77
|
+
let moduleUrl: string
|
|
78
|
+
}
|
|
79
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Module types for a DCB outbound translation slice.
|
|
3
|
+
|
|
4
|
+
An `OutboundTranslationSlice` listens to the shared `DcbEventLog` event topic,
|
|
5
|
+
collects outbound items (TODO list), and calls an external service for each one.
|
|
6
|
+
The translate function may optionally return a command to publish back into the
|
|
7
|
+
system, closing the loop.
|
|
8
|
+
|
|
9
|
+
Replaces fire-and-forget `SideEffectHandler` with tracked, retryable external calls.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Event(s) -> TODO List (read model) -> Translator -> External Service
|
|
13
|
+
-> Command (optional)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Plan 02 splits the merged spec into two module types:
|
|
17
|
+
|
|
18
|
+
- `Spec` — types, identity, schemas, sweep config. Per D2, `outboundItem`
|
|
19
|
+
lives here (persisted TODO state with schema).
|
|
20
|
+
- `Translation` — `collect` (sync, observable in tests) and `translate`
|
|
21
|
+
(async, mocked in tests via `whenTranslateMocked`).
|
|
22
|
+
|
|
23
|
+
@example
|
|
24
|
+
```rescript
|
|
25
|
+
// SendTrackingEmail.res
|
|
26
|
+
let name = "SendTrackingEmail"
|
|
27
|
+
|
|
28
|
+
@schema type outboundItem = {orderId: string, email: string}
|
|
29
|
+
@schema type inboundCommand = unit
|
|
30
|
+
|
|
31
|
+
let collect = event => switch event {
|
|
32
|
+
| OrderShipped({orderId, email}) => [(orderId, {orderId, email})]
|
|
33
|
+
| _ => []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let translate = async (_id, item) => {
|
|
37
|
+
await EmailService.send(item.email, ~orderId=item.orderId)
|
|
38
|
+
Ok(None) // fire-and-forget: no command back
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let maxRetries = 3
|
|
42
|
+
let heartbeatInterval = 60
|
|
43
|
+
```
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
The lean Spec for an OutboundTranslationSlice — types, identity, schemas, sweep config.
|
|
48
|
+
*/
|
|
49
|
+
module type Spec = {
|
|
50
|
+
/** Logical name of this outbound translation slice (used as a component prefix). */
|
|
51
|
+
let name: string
|
|
52
|
+
let moduleUrl: string
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
Events this outbound translation slice consumes for collect.
|
|
56
|
+
Only needs the fields required — no tag annotations needed.
|
|
57
|
+
Must carry `@schema`.
|
|
58
|
+
*/
|
|
59
|
+
@schema
|
|
60
|
+
type consumedEvent
|
|
61
|
+
|
|
62
|
+
/** The outbound item state — what data is accumulated for each pending external call. Must carry `@schema`. */
|
|
63
|
+
@schema
|
|
64
|
+
type outboundItem
|
|
65
|
+
|
|
66
|
+
/** The command type optionally produced after a successful translate call. Must carry `@schema`. */
|
|
67
|
+
@schema
|
|
68
|
+
type inboundCommand
|
|
69
|
+
|
|
70
|
+
/** Maximum number of retries for a failed translate attempt. */
|
|
71
|
+
let maxRetries: int
|
|
72
|
+
|
|
73
|
+
/** Heartbeat interval in seconds for sweeping pending/failed items. */
|
|
74
|
+
let heartbeatInterval: int
|
|
75
|
+
|
|
76
|
+
/** Name of the aggregate or StateChangeSlice that receives the inbound command, or None for fire-and-forget. */
|
|
77
|
+
let targetName: option<string>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
The Translation — `collect` and async `translate`. Both functions are
|
|
82
|
+
distinguished from the Inbound shape (which has only a sync `translate`).
|
|
83
|
+
*/
|
|
84
|
+
module type Translation = {
|
|
85
|
+
module Spec: Spec
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
Collect: map an incoming event to zero or more new outbound items.
|
|
89
|
+
Each item has an `id` (deduplication key) and the `outboundItem` payload.
|
|
90
|
+
Returns empty array if this event is not relevant.
|
|
91
|
+
*/
|
|
92
|
+
let collect: Spec.consumedEvent => array<(string, Spec.outboundItem)>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
Translate: call the external service for a single outbound item.
|
|
96
|
+
Returns:
|
|
97
|
+
- `Ok(Some((targetId, cmd)))` to publish a command back into the system
|
|
98
|
+
- `Ok(None)` for fire-and-forget (no command back)
|
|
99
|
+
- `Error(msg)` on failure (item will be retried up to maxRetries)
|
|
100
|
+
*/
|
|
101
|
+
let translate: (string, Spec.outboundItem) => promise<
|
|
102
|
+
result<option<(string, Spec.inboundCommand)>, string>,
|
|
103
|
+
>
|
|
104
|
+
|
|
105
|
+
/** File URL of this Translation module (`import.meta.url`). */
|
|
106
|
+
let moduleUrl: string
|
|
107
|
+
}
|
|
108
|
+
|