@reventlessdev/reventless-spec 3.0.0-alpha.100
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 +931 -0
- package/LICENSE +202 -0
- package/README.md +109 -0
- package/package.json +49 -0
- package/rescript.json +32 -0
- package/run-generator.mjs +2 -0
- package/run-platform-generator.mjs +2 -0
- package/scripts/generate-currency.mjs +215 -0
- package/scripts/iso-4217-list-one.xml +1956 -0
- package/src/AnsiStyle.res +40 -0
- package/src/AnsiStyle.res.mjs +54 -0
- package/src/LogPrefix.res +192 -0
- package/src/LogPrefix.res.mjs +159 -0
- package/src/PackageVersion.res +67 -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/CapabilityManifest.res +74 -0
- package/src/components/CapabilityManifest.res.mjs +61 -0
- package/src/components/ComponentKind.res +99 -0
- package/src/components/ComponentKind.res.mjs +125 -0
- package/src/components/Counter.res +24 -0
- package/src/components/Counter.res.mjs +2 -0
- package/src/components/DcbDecode.res +118 -0
- package/src/components/DcbDecode.res.mjs +100 -0
- package/src/components/DcbScopeInference.res +244 -0
- package/src/components/DcbScopeInference.res.mjs +177 -0
- package/src/components/DcbTag.res +1335 -0
- package/src/components/DcbTag.res.mjs +898 -0
- package/src/components/DcbValidation.res +427 -0
- package/src/components/DcbValidation.res.mjs +423 -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 +85 -0
- package/src/components/InboundTranslationSlice.res.mjs +2 -0
- package/src/components/OutboundTranslationSlice.res +153 -0
- package/src/components/OutboundTranslationSlice.res.mjs +2 -0
- package/src/components/Plugin.res +538 -0
- package/src/components/Plugin.res.mjs +264 -0
- package/src/components/PluginName.res +39 -0
- package/src/components/PluginName.res.mjs +45 -0
- package/src/components/ReadModel.res +199 -0
- package/src/components/ReadModel.res.mjs +18 -0
- package/src/components/Reference.res +55 -0
- package/src/components/Reference.res.mjs +50 -0
- package/src/components/Snapshot.res +26 -0
- package/src/components/Snapshot.res.mjs +2 -0
- package/src/components/StateAnnotations.res +97 -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 +123 -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 +842 -0
- package/src/generator/Codegen.res.mjs +565 -0
- package/src/generator/Config.res +106 -0
- package/src/generator/Config.res.mjs +69 -0
- package/src/generator/Discovery.res +230 -0
- package/src/generator/Discovery.res.mjs +198 -0
- package/src/generator/Generator_Node.res +14 -0
- package/src/generator/Generator_Node.res.mjs +18 -0
- package/src/generator/Pairing.res +460 -0
- package/src/generator/Pairing.res.mjs +415 -0
- package/src/generator/PlatformCodegen.res +207 -0
- package/src/generator/PlatformCodegen.res.mjs +154 -0
- package/src/generator/PlatformGenerator.res +126 -0
- package/src/generator/PlatformGenerator.res.mjs +114 -0
- package/src/generator/PlatformManifests.res +203 -0
- package/src/generator/PlatformManifests.res.mjs +212 -0
- package/src/generator/PluginGenerator.res +57 -0
- package/src/generator/PluginGenerator.res.mjs +73 -0
- package/src/semantic/Bytes.res +54 -0
- package/src/semantic/Bytes.res.mjs +38 -0
- package/src/semantic/Capabilities.res +43 -0
- package/src/semantic/Capabilities.res.mjs +17 -0
- package/src/semantic/Color.res +51 -0
- package/src/semantic/Color.res.mjs +29 -0
- package/src/semantic/Currency.res +598 -0
- package/src/semantic/Currency.res.mjs +743 -0
- package/src/semantic/DateRange.res +148 -0
- package/src/semantic/DateRange.res.mjs +74 -0
- package/src/semantic/Duration.res +53 -0
- package/src/semantic/Duration.res.mjs +26 -0
- package/src/semantic/Email.res +51 -0
- package/src/semantic/Email.res.mjs +31 -0
- package/src/semantic/GeoPoint.res +226 -0
- package/src/semantic/GeoPoint.res.mjs +190 -0
- package/src/semantic/Geocoding.res +127 -0
- package/src/semantic/Geocoding.res.mjs +36 -0
- package/src/semantic/Money.res +196 -0
- package/src/semantic/Money.res.mjs +138 -0
- package/src/semantic/Offload.res +294 -0
- package/src/semantic/Offload.res.mjs +191 -0
- package/src/semantic/Percent.res +53 -0
- package/src/semantic/Percent.res.mjs +33 -0
- package/src/semantic/Phone.res +55 -0
- package/src/semantic/Phone.res.mjs +29 -0
- package/src/semantic/Semantic.res +162 -0
- package/src/semantic/Semantic.res.mjs +95 -0
- package/src/semantic/StorageRef.res +164 -0
- package/src/semantic/StorageRef.res.mjs +111 -0
- package/src/semantic/Url.res +66 -0
- package/src/semantic/Url.res.mjs +48 -0
- package/src/types/Authorization.res +23 -0
- package/src/types/Authorization.res.mjs +33 -0
- package/src/types/Behavior.res +86 -0
- package/src/types/Behavior.res.mjs +2 -0
- package/src/types/DateTime.res +29 -0
- package/src/types/DateTime.res.mjs +16 -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 +326 -0
- package/src/types/Message.res.mjs +186 -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,118 @@
|
|
|
1
|
+
// Decodes raw stored events against a consumed event schema.
|
|
2
|
+
// Supports the full spectrum of consumed events:
|
|
3
|
+
// - Payload-less variants (match by TAG only, bypass sury)
|
|
4
|
+
// - Partial projections (sury parse with field subset)
|
|
5
|
+
// - Full shape (sury parse as-is)
|
|
6
|
+
|
|
7
|
+
// Build a lookup of TAG name -> variant kind (payloadLess or withFields)
|
|
8
|
+
// from a consumed event schema. Cached per schema for performance.
|
|
9
|
+
|
|
10
|
+
type variantKind =
|
|
11
|
+
| PayloadLess
|
|
12
|
+
| WithFields
|
|
13
|
+
|
|
14
|
+
let buildVariantLookup = (schema: S.t<unknown>): dict<variantKind> => {
|
|
15
|
+
let lookup = Dict.make()
|
|
16
|
+
switch schema {
|
|
17
|
+
| Union({anyOf}) =>
|
|
18
|
+
anyOf->Array.forEach(variantSchema =>
|
|
19
|
+
switch variantSchema {
|
|
20
|
+
| Object({items, properties}) =>
|
|
21
|
+
switch DcbTag.variantTagName(items) {
|
|
22
|
+
| Some(name) =>
|
|
23
|
+
let kind = if properties->Dict.keysToArray->Array.length == 0 {
|
|
24
|
+
PayloadLess
|
|
25
|
+
} else {
|
|
26
|
+
WithFields
|
|
27
|
+
}
|
|
28
|
+
lookup->Dict.set(name, kind)
|
|
29
|
+
| None => ()
|
|
30
|
+
}
|
|
31
|
+
| String({const: ?Some(name)}) =>
|
|
32
|
+
// sury represents payload-less variants in some cases as bare String({const})
|
|
33
|
+
lookup->Dict.set(name, PayloadLess)
|
|
34
|
+
| _ => ()
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
| Object({items}) =>
|
|
38
|
+
// Single-variant schema with fields
|
|
39
|
+
switch DcbTag.variantTagName(items) {
|
|
40
|
+
| Some(name) => lookup->Dict.set(name, WithFields)
|
|
41
|
+
| None => ()
|
|
42
|
+
}
|
|
43
|
+
| String({const: ?Some(name)}) =>
|
|
44
|
+
// Single payload-less variant — sury compiles `type t = Foo` to String({const: "Foo"})
|
|
45
|
+
lookup->Dict.set(name, PayloadLess)
|
|
46
|
+
| _ => ()
|
|
47
|
+
}
|
|
48
|
+
lookup
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type decoder<'event> = {
|
|
52
|
+
lookup: dict<variantKind>,
|
|
53
|
+
schema: S.t<'event>,
|
|
54
|
+
constructors: dict<'event>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
external unsafeCast: 'a => 'b = "%identity"
|
|
58
|
+
|
|
59
|
+
// Build a map of TAG name -> constructed payload-less variant value
|
|
60
|
+
// by encoding each payload-less variant through sury and storing the result.
|
|
61
|
+
// This is done once per schema, not per event.
|
|
62
|
+
let buildPayloadLessConstructors = (schema: S.t<'event>, lookup: dict<variantKind>): dict<'event> => {
|
|
63
|
+
let constructors = Dict.make()
|
|
64
|
+
// For payload-less variants, we need to figure out what ReScript value
|
|
65
|
+
// corresponds to each TAG name. sury's schema for a union with payload-less
|
|
66
|
+
// variants encodes them as bare strings. So `| ItemCreated` serializes to
|
|
67
|
+
// JSON string "ItemCreated". We can use S.parseJsonOrThrow with the string.
|
|
68
|
+
lookup->Dict.toArray->Array.forEach(((tagName, kind)) =>
|
|
69
|
+
switch kind {
|
|
70
|
+
| PayloadLess =>
|
|
71
|
+
try {
|
|
72
|
+
let value = JSON.String(tagName)->S.parseJsonOrThrow(schema)
|
|
73
|
+
constructors->Dict.set(tagName, value)
|
|
74
|
+
} catch {
|
|
75
|
+
| _ => ()
|
|
76
|
+
}
|
|
77
|
+
| WithFields => ()
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
constructors
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type makeDecoderResult<'event> = {
|
|
84
|
+
decode: (~eventType: string, ~data: dict<JSON.t>) => option<'event>,
|
|
85
|
+
eventTypes: array<string>,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let makeDecoder = (schema: S.t<'event>): makeDecoderResult<'event> => {
|
|
89
|
+
let unknownSchema: S.t<unknown> = unsafeCast(schema)
|
|
90
|
+
let lookup = buildVariantLookup(unknownSchema)
|
|
91
|
+
let constructors = buildPayloadLessConstructors(schema, lookup)
|
|
92
|
+
let eventTypes = lookup->Dict.keysToArray
|
|
93
|
+
|
|
94
|
+
let decode = (~eventType: string, ~data: dict<JSON.t>): option<'event> =>
|
|
95
|
+
switch lookup->Dict.get(eventType) {
|
|
96
|
+
| None => None
|
|
97
|
+
| Some(PayloadLess) => constructors->Dict.get(eventType)
|
|
98
|
+
| Some(WithFields) =>
|
|
99
|
+
// Reconstruct the JSON with TAG for sury parsing
|
|
100
|
+
let jsonDict = data->Dict.copy
|
|
101
|
+
jsonDict->Dict.set("TAG", JSON.String(eventType))
|
|
102
|
+
try {
|
|
103
|
+
Some(JSON.Object(jsonDict)->S.parseJsonOrThrow(schema))
|
|
104
|
+
} catch {
|
|
105
|
+
| _ =>
|
|
106
|
+
// A parse failure here means a stored event no longer matches the
|
|
107
|
+
// current schema (drift). Dropping it silently hid real data loss;
|
|
108
|
+
// surface it as a warning so it's diagnosable.
|
|
109
|
+
Console.warn(
|
|
110
|
+
"DcbDecode: dropped event `" ++
|
|
111
|
+
eventType ++ "` — payload does not match the current schema (drift?)",
|
|
112
|
+
)
|
|
113
|
+
None
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
{decode, eventTypes}
|
|
118
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
5
|
+
import * as DcbTag$Reventless from "./DcbTag.res.mjs";
|
|
6
|
+
|
|
7
|
+
function buildVariantLookup(schema) {
|
|
8
|
+
let lookup = {};
|
|
9
|
+
switch (schema.type) {
|
|
10
|
+
case "string" :
|
|
11
|
+
let name = schema.const;
|
|
12
|
+
if (name !== undefined) {
|
|
13
|
+
lookup[name] = "PayloadLess";
|
|
14
|
+
}
|
|
15
|
+
break;
|
|
16
|
+
case "object" :
|
|
17
|
+
let name$1 = DcbTag$Reventless.variantTagName(schema.items);
|
|
18
|
+
if (name$1 !== undefined) {
|
|
19
|
+
lookup[name$1] = "WithFields";
|
|
20
|
+
}
|
|
21
|
+
break;
|
|
22
|
+
case "union" :
|
|
23
|
+
schema.anyOf.forEach(variantSchema => {
|
|
24
|
+
switch (variantSchema.type) {
|
|
25
|
+
case "string" :
|
|
26
|
+
let name = variantSchema.const;
|
|
27
|
+
if (name !== undefined) {
|
|
28
|
+
lookup[name] = "PayloadLess";
|
|
29
|
+
return;
|
|
30
|
+
} else {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
case "object" :
|
|
34
|
+
let name$1 = DcbTag$Reventless.variantTagName(variantSchema.items);
|
|
35
|
+
if (name$1 === undefined) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let kind = Object.keys(variantSchema.properties).length === 0 ? "PayloadLess" : "WithFields";
|
|
39
|
+
lookup[name$1] = kind;
|
|
40
|
+
return;
|
|
41
|
+
default:
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return lookup;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildPayloadLessConstructors(schema, lookup) {
|
|
51
|
+
let constructors = {};
|
|
52
|
+
Object.entries(lookup).forEach(param => {
|
|
53
|
+
let tagName = param[0];
|
|
54
|
+
if (param[1] !== "PayloadLess") {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
let value = S.parseJsonOrThrow(tagName, schema);
|
|
59
|
+
constructors[tagName] = value;
|
|
60
|
+
return;
|
|
61
|
+
} catch (exn) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return constructors;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeDecoder(schema) {
|
|
69
|
+
let lookup = buildVariantLookup(schema);
|
|
70
|
+
let constructors = buildPayloadLessConstructors(schema, lookup);
|
|
71
|
+
let eventTypes = Object.keys(lookup);
|
|
72
|
+
let decode = (eventType, data) => {
|
|
73
|
+
let match = lookup[eventType];
|
|
74
|
+
if (match === undefined) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (match === "PayloadLess") {
|
|
78
|
+
return constructors[eventType];
|
|
79
|
+
}
|
|
80
|
+
let jsonDict = Object.assign({}, data);
|
|
81
|
+
jsonDict["TAG"] = eventType;
|
|
82
|
+
try {
|
|
83
|
+
return Primitive_option.some(S.parseJsonOrThrow(jsonDict, schema));
|
|
84
|
+
} catch (exn) {
|
|
85
|
+
console.warn("DcbDecode: dropped event `" + eventType + "` — payload does not match the current schema (drift?)");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
decode: decode,
|
|
91
|
+
eventTypes: eventTypes
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
buildVariantLookup,
|
|
97
|
+
buildPayloadLessConstructors,
|
|
98
|
+
makeDecoder,
|
|
99
|
+
}
|
|
100
|
+
/* S Not a pure module */
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
DCB tag-scope inference (Phase 1 — pure core).
|
|
3
|
+
|
|
4
|
+
Derives DCB tag scope from the *global* slice graph instead of hand-placed
|
|
5
|
+
`@partitionTag` / `@crossPartition` / `@noTag` annotations. This module is
|
|
6
|
+
deliberately **schema-agnostic**: its input carries only the structural shape of
|
|
7
|
+
each slice (variant names + `*Id`-shaped fields), never an `S.t` schema and never
|
|
8
|
+
a tag-metadata flag. That is precisely what we are replacing — so both the runtime
|
|
9
|
+
(building shapes from `S.t` schemas, via `DcbTag.sliceShapeFromSchemas`) and the
|
|
10
|
+
VS Code tooling (building shapes from parsed `.res` source) can feed the same
|
|
11
|
+
`infer`. See `docs/plans/dcb-tag-scope-inference.md` § "Phase 1 design".
|
|
12
|
+
|
|
13
|
+
The three rules (over the representation):
|
|
14
|
+
|
|
15
|
+
1. **Owner / partition.** A slice's partition key is the key its *own* emitted
|
|
16
|
+
events are identified by — computed as `producedKeys(S)` minus the keys `S`
|
|
17
|
+
reads from a *foreign* producer (a consumed arm whose event type is produced by
|
|
18
|
+
a different slice). For `AddProduct` (`ProductAdded({productId, categoryId})`,
|
|
19
|
+
consuming `CategoryAdded({categoryId})`) the foreign-read `categoryId` is
|
|
20
|
+
removed, leaving `productId`. No fixpoint needed.
|
|
21
|
+
|
|
22
|
+
2. **Cross-partition.** A key is cross-partition iff some slice reads it on a
|
|
23
|
+
*foreign* consumed event while partitioned by something else *and* the key is
|
|
24
|
+
another entity's partition. `AddProduct` reads `categoryId` (Category's
|
|
25
|
+
partition) while partitioned by `productId` ⇒ `categoryId` is cross-partition.
|
|
26
|
+
|
|
27
|
+
3. **Index vs payload.** A `*Id` on an *emitted* event is indexed iff it is the
|
|
28
|
+
producing slice's own partition key. Foreign reference keys (e.g. `categoryId`
|
|
29
|
+
on `ProductAdded`) are payload ⇒ not indexed ⇒ the sibling-leak GSI write never
|
|
30
|
+
happens.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** A `*Id` / `*Ids`-shaped field, identified by name only (no schema, no tag flag). */
|
|
34
|
+
type idField = {name: string, isList: bool}
|
|
35
|
+
|
|
36
|
+
/** One variant arm: its constructor name and the `*Id` fields it carries. */
|
|
37
|
+
type eventShape = {eventType: string, idFields: array<idField>}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
The structural shape of one slice, the boundary type both adapters produce:
|
|
41
|
+
- `command` — the `*Id` fields on the slice's command,
|
|
42
|
+
- `consumed` — the arms (and their `*Id` fields) the slice reads,
|
|
43
|
+
- `produced` — the arms (and their `*Id` fields) the slice writes,
|
|
44
|
+
- `partitionHint` — an explicit `@partitionTag` escape hatch (when the dev marked
|
|
45
|
+
the partition because the slice's own events legitimately carry two owned keys,
|
|
46
|
+
e.g. `RecordProductDemand`). Overrides the inferred partition.
|
|
47
|
+
*/
|
|
48
|
+
type sliceShape = {
|
|
49
|
+
sliceName: string,
|
|
50
|
+
command: array<idField>,
|
|
51
|
+
consumed: array<eventShape>,
|
|
52
|
+
produced: array<eventShape>,
|
|
53
|
+
partitionHint: option<string>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Derived scope for one tag key. */
|
|
57
|
+
type scope = Partition | CrossPartition | Payload
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
The full derivation. `crossPartitionTagKeys` and `tagKeysByEventType` are the two
|
|
61
|
+
values the runtime threads today (via `DcbTag.extractCrossPartitionTagKeys` /
|
|
62
|
+
`mergeTagKeysByEventType`); under inference they are produced here.
|
|
63
|
+
*/
|
|
64
|
+
type derived = {
|
|
65
|
+
/** sliceName -> its inferred partition key (absent when ambiguous). */
|
|
66
|
+
partitionBySlice: dict<string>,
|
|
67
|
+
/** tag key -> the name of a slice that owns it as its partition. */
|
|
68
|
+
ownerByKey: dict<string>,
|
|
69
|
+
/** keys read cross-partition by some slice (sorted, deduped). */
|
|
70
|
+
crossPartitionTagKeys: array<string>,
|
|
71
|
+
/** produced eventType -> its indexed (non-payload) tag keys (sorted). */
|
|
72
|
+
tagKeysByEventType: dict<array<string>>,
|
|
73
|
+
/** (sliceName, reason) for slices whose partition couldn't be inferred. */
|
|
74
|
+
ambiguities: array<(string, string)>,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
The tag key for a `*Id`-shaped field. A plural `*Ids: array<string>` shares the
|
|
79
|
+
singular producer's key (trailing `s` stripped — `productIds` -> `productId`);
|
|
80
|
+
a scalar `*Id` uses the field name verbatim. Mirrors the PPX's `*Ids` rule.
|
|
81
|
+
*/
|
|
82
|
+
let tagKeyOf = (f: idField): string =>
|
|
83
|
+
if f.isList && f.name->String.endsWith("s") {
|
|
84
|
+
f.name->String.slice(~start=0, ~end=f.name->String.length - 1)
|
|
85
|
+
} else {
|
|
86
|
+
f.name
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let dedupSorted = (keys: array<string>): array<string> => {
|
|
90
|
+
let seen = Set.make()
|
|
91
|
+
keys->Array.forEach(k => seen->Set.add(k))
|
|
92
|
+
Array.fromIterator(seen->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let keysOfEvent = (e: eventShape): array<string> => e.idFields->Array.map(tagKeyOf)
|
|
96
|
+
|
|
97
|
+
let producedKeys = (s: sliceShape): array<string> =>
|
|
98
|
+
dedupSorted(s.produced->Array.flatMap(keysOfEvent))
|
|
99
|
+
|
|
100
|
+
let consumedKeys = (s: sliceShape): array<string> =>
|
|
101
|
+
dedupSorted(s.consumed->Array.flatMap(keysOfEvent))
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
Keys the command carries as a **scalar** (`*Id: string`, not `*Ids: array<string>`).
|
|
105
|
+
A scalar tag is AND-ed with the partition into one composite clause unless it is
|
|
106
|
+
read cross-partition, so only a scalar foreign reference needs the cross-partition
|
|
107
|
+
fan. A foreign key the command carries *only* as an array already fans per element
|
|
108
|
+
and stays partition-scoped (it reads the foreign entity's own partition) — the
|
|
109
|
+
`PlaceOrder`/`productIds` shape — so it is **not** cross-partition.
|
|
110
|
+
*/
|
|
111
|
+
let commandScalarKeys = (s: sliceShape): array<string> =>
|
|
112
|
+
dedupSorted(s.command->Array.filter(f => !f.isList)->Array.map(tagKeyOf))
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
Infers DCB tag scope for a set of slices (one DCB consistency boundary / plugin).
|
|
116
|
+
Pure and total — never throws; unresolvable partitions land in `ambiguities`.
|
|
117
|
+
*/
|
|
118
|
+
/**
|
|
119
|
+
The keys a slice reads from a *foreign* event — a consumed arm whose event type
|
|
120
|
+
the slice does **not** itself produce. These are the candidate cross-entity
|
|
121
|
+
references; they cannot be the slice's own partition. Defined on a single shape
|
|
122
|
+
so it works both globally (in `infer`) and per-slice (in the GWT harness, which
|
|
123
|
+
sees only one slice).
|
|
124
|
+
*/
|
|
125
|
+
let foreignConsumedKeys = (s: sliceShape): array<string> => {
|
|
126
|
+
let ownProduced = Set.make()
|
|
127
|
+
s.produced->Array.forEach(e => ownProduced->Set.add(e.eventType))
|
|
128
|
+
dedupSorted(
|
|
129
|
+
s.consumed->Array.flatMap(e => ownProduced->Set.has(e.eventType) ? [] : e->keysOfEvent),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
Per-slice cross-partition keys for the test harness, which has no global owner
|
|
135
|
+
map: a foreign-read key that is not the slice's own partition is read across
|
|
136
|
+
partitions. Matches `infer`'s global rule 2 for the common "reference another
|
|
137
|
+
entity" case; the harness unions this with any explicit `@crossPartition`
|
|
138
|
+
annotation so capacity/escape-hatch reads remain covered.
|
|
139
|
+
*/
|
|
140
|
+
let crossPartitionForSlice = (s: sliceShape): array<string> => {
|
|
141
|
+
let foreign = foreignConsumedKeys(s)
|
|
142
|
+
let scalar = commandScalarKeys(s)
|
|
143
|
+
let partition = switch s.partitionHint {
|
|
144
|
+
| Some(h) if producedKeys(s)->Array.includes(h) => Some(h)
|
|
145
|
+
| _ =>
|
|
146
|
+
switch producedKeys(s)->Array.filter(k => !(foreign->Array.includes(k))) {
|
|
147
|
+
| [single] => Some(single)
|
|
148
|
+
| _ => None
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// A foreign read is cross-partition only when the command carries the key as a
|
|
152
|
+
// scalar (must be fanned); an array-only foreign key auto-fans partition-scoped.
|
|
153
|
+
foreign->Array.filter(k => Some(k) != partition && scalar->Array.includes(k))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let infer = (slices: array<sliceShape>): derived => {
|
|
157
|
+
// Rule 1 — partition(S) = producedKeys(S) \ foreignConsumedKeys(S), where a
|
|
158
|
+
// foreign-consumed key rides a consumed arm the slice does not itself produce.
|
|
159
|
+
// An explicit @partitionTag hint overrides the derivation (escape hatch for
|
|
160
|
+
// slices whose own events legitimately carry two owned keys).
|
|
161
|
+
let partitionBySlice = Dict.make()
|
|
162
|
+
let ambiguities = []
|
|
163
|
+
slices->Array.forEach(s => {
|
|
164
|
+
let produced = producedKeys(s)
|
|
165
|
+
switch s.partitionHint {
|
|
166
|
+
| Some(h) if produced->Array.includes(h) => partitionBySlice->Dict.set(s.sliceName, h)
|
|
167
|
+
| _ =>
|
|
168
|
+
let foreign = foreignConsumedKeys(s)
|
|
169
|
+
switch produced->Array.filter(k => !(foreign->Array.includes(k))) {
|
|
170
|
+
| [single] => partitionBySlice->Dict.set(s.sliceName, single)
|
|
171
|
+
| [] =>
|
|
172
|
+
let _ = ambiguities->Array.push((
|
|
173
|
+
s.sliceName,
|
|
174
|
+
"no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag",
|
|
175
|
+
))
|
|
176
|
+
| many =>
|
|
177
|
+
let _ = ambiguities->Array.push((
|
|
178
|
+
s.sliceName,
|
|
179
|
+
`multiple candidate partition keys (${many->Array.join(", ")}) — add an explicit @partitionTag`,
|
|
180
|
+
))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// owner map + the set of keys that are *some* entity's partition.
|
|
186
|
+
let ownerByKey = Dict.make()
|
|
187
|
+
let ownedPartitionKeys = Set.make()
|
|
188
|
+
slices->Array.forEach(s =>
|
|
189
|
+
switch partitionBySlice->Dict.get(s.sliceName) {
|
|
190
|
+
| Some(k) =>
|
|
191
|
+
ownedPartitionKeys->Set.add(k)
|
|
192
|
+
switch ownerByKey->Dict.get(k) {
|
|
193
|
+
| Some(_) => ()
|
|
194
|
+
| None => ownerByKey->Dict.set(k, s.sliceName)
|
|
195
|
+
}
|
|
196
|
+
| None => ()
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
// Rule 2 — a key read on a foreign consumed event that is another entity's
|
|
201
|
+
// partition (and not this slice's own partition) is cross-partition — but only
|
|
202
|
+
// when the command carries it as a SCALAR. A foreign key the command carries
|
|
203
|
+
// only as an array auto-fans per element and stays partition-scoped (it reads
|
|
204
|
+
// the foreign entity's own partition), so it is not cross-partition.
|
|
205
|
+
let crossKeys = Set.make()
|
|
206
|
+
slices->Array.forEach(s => {
|
|
207
|
+
let own = partitionBySlice->Dict.get(s.sliceName)
|
|
208
|
+
let scalar = commandScalarKeys(s)
|
|
209
|
+
s->consumedKeys->Array.forEach(k =>
|
|
210
|
+
if ownedPartitionKeys->Set.has(k) && Some(k) != own && scalar->Array.includes(k) {
|
|
211
|
+
crossKeys->Set.add(k)
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
let crossPartitionTagKeys =
|
|
216
|
+
Array.fromIterator(crossKeys->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
|
|
217
|
+
|
|
218
|
+
// Rule 3 — a produced key is indexed iff it is the producing slice's own
|
|
219
|
+
// partition OR some slice issues a decision read of *that event type* by it
|
|
220
|
+
// (the key appears on a consumed arm naming the event type). Foreign reference
|
|
221
|
+
// keys that nobody reads this event type by are payload ⇒ no GSI write ⇒ the
|
|
222
|
+
// sibling leak is impossible. The read-by-anybody arm is what keeps a composite
|
|
223
|
+
// own-stream read (e.g. ProductDemandRecorded read by orderId) — and an M:N
|
|
224
|
+
// capacity read — correctly indexed without a hand annotation.
|
|
225
|
+
let readKeysByEventType = Dict.make()
|
|
226
|
+
slices->Array.forEach(s =>
|
|
227
|
+
s.consumed->Array.forEach(e => {
|
|
228
|
+
let prev = readKeysByEventType->Dict.get(e.eventType)->Option.getOr([])
|
|
229
|
+
readKeysByEventType->Dict.set(e.eventType, prev->Array.concat(e->keysOfEvent))
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
let tagKeysByEventType = Dict.make()
|
|
233
|
+
slices->Array.forEach(s => {
|
|
234
|
+
let own = partitionBySlice->Dict.get(s.sliceName)
|
|
235
|
+
s.produced->Array.forEach(e => {
|
|
236
|
+
let readKeys = readKeysByEventType->Dict.get(e.eventType)->Option.getOr([])
|
|
237
|
+
let indexed =
|
|
238
|
+
e->keysOfEvent->Array.filter(k => Some(k) == own || readKeys->Array.includes(k))
|
|
239
|
+
tagKeysByEventType->Dict.set(e.eventType, dedupSorted(indexed))
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
{partitionBySlice, ownerByKey, crossPartitionTagKeys, tagKeysByEventType, ambiguities}
|
|
244
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
5
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
6
|
+
|
|
7
|
+
function tagKeyOf(f) {
|
|
8
|
+
if (f.isList && f.name.endsWith("s")) {
|
|
9
|
+
return f.name.slice(0, f.name.length - 1 | 0);
|
|
10
|
+
} else {
|
|
11
|
+
return f.name;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function dedupSorted(keys) {
|
|
16
|
+
let seen = new Set();
|
|
17
|
+
keys.forEach(k => {
|
|
18
|
+
seen.add(k);
|
|
19
|
+
});
|
|
20
|
+
return Array.from(seen.values()).toSorted(Primitive_string.compare);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function keysOfEvent(e) {
|
|
24
|
+
return e.idFields.map(tagKeyOf);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function producedKeys(s) {
|
|
28
|
+
return dedupSorted(s.produced.flatMap(keysOfEvent));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function consumedKeys(s) {
|
|
32
|
+
return dedupSorted(s.consumed.flatMap(keysOfEvent));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function commandScalarKeys(s) {
|
|
36
|
+
return dedupSorted(s.command.filter(f => !f.isList).map(tagKeyOf));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function foreignConsumedKeys(s) {
|
|
40
|
+
let ownProduced = new Set();
|
|
41
|
+
s.produced.forEach(e => {
|
|
42
|
+
ownProduced.add(e.eventType);
|
|
43
|
+
});
|
|
44
|
+
return dedupSorted(s.consumed.flatMap(e => {
|
|
45
|
+
if (ownProduced.has(e.eventType)) {
|
|
46
|
+
return [];
|
|
47
|
+
} else {
|
|
48
|
+
return e.idFields.map(tagKeyOf);
|
|
49
|
+
}
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function crossPartitionForSlice(s) {
|
|
54
|
+
let foreign = foreignConsumedKeys(s);
|
|
55
|
+
let scalar = commandScalarKeys(s);
|
|
56
|
+
let h = s.partitionHint;
|
|
57
|
+
let partition;
|
|
58
|
+
let exit = 0;
|
|
59
|
+
if (h !== undefined && producedKeys(s).includes(h)) {
|
|
60
|
+
partition = h;
|
|
61
|
+
} else {
|
|
62
|
+
exit = 1;
|
|
63
|
+
}
|
|
64
|
+
if (exit === 1) {
|
|
65
|
+
let match = producedKeys(s).filter(k => !foreign.includes(k));
|
|
66
|
+
partition = match.length !== 1 ? undefined : match[0];
|
|
67
|
+
}
|
|
68
|
+
return foreign.filter(k => {
|
|
69
|
+
if (Primitive_object.notequal(k, partition)) {
|
|
70
|
+
return scalar.includes(k);
|
|
71
|
+
} else {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function infer(slices) {
|
|
78
|
+
let partitionBySlice = {};
|
|
79
|
+
let ambiguities = [];
|
|
80
|
+
slices.forEach(s => {
|
|
81
|
+
let produced = producedKeys(s);
|
|
82
|
+
let h = s.partitionHint;
|
|
83
|
+
if (h !== undefined && produced.includes(h)) {
|
|
84
|
+
partitionBySlice[s.sliceName] = h;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let foreign = foreignConsumedKeys(s);
|
|
88
|
+
let many = produced.filter(k => !foreign.includes(k));
|
|
89
|
+
let len = many.length;
|
|
90
|
+
if (len !== 1) {
|
|
91
|
+
if (len !== 0) {
|
|
92
|
+
ambiguities.push([
|
|
93
|
+
s.sliceName,
|
|
94
|
+
`multiple candidate partition keys (` + many.join(", ") + `) — add an explicit @partitionTag`
|
|
95
|
+
]);
|
|
96
|
+
} else {
|
|
97
|
+
ambiguities.push([
|
|
98
|
+
s.sliceName,
|
|
99
|
+
"no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag"
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let single = many[0];
|
|
105
|
+
partitionBySlice[s.sliceName] = single;
|
|
106
|
+
});
|
|
107
|
+
let ownerByKey = {};
|
|
108
|
+
let ownedPartitionKeys = new Set();
|
|
109
|
+
slices.forEach(s => {
|
|
110
|
+
let k = partitionBySlice[s.sliceName];
|
|
111
|
+
if (k === undefined) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
ownedPartitionKeys.add(k);
|
|
115
|
+
let match = ownerByKey[k];
|
|
116
|
+
if (match !== undefined) {
|
|
117
|
+
return;
|
|
118
|
+
} else {
|
|
119
|
+
ownerByKey[k] = s.sliceName;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
let crossKeys = new Set();
|
|
124
|
+
slices.forEach(s => {
|
|
125
|
+
let own = partitionBySlice[s.sliceName];
|
|
126
|
+
let scalar = commandScalarKeys(s);
|
|
127
|
+
consumedKeys(s).forEach(k => {
|
|
128
|
+
if (ownedPartitionKeys.has(k) && Primitive_object.notequal(k, own) && scalar.includes(k)) {
|
|
129
|
+
crossKeys.add(k);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
let crossPartitionTagKeys = Array.from(crossKeys.values()).toSorted(Primitive_string.compare);
|
|
135
|
+
let readKeysByEventType = {};
|
|
136
|
+
slices.forEach(s => {
|
|
137
|
+
s.consumed.forEach(e => {
|
|
138
|
+
let prev = Stdlib_Option.getOr(readKeysByEventType[e.eventType], []);
|
|
139
|
+
readKeysByEventType[e.eventType] = prev.concat(e.idFields.map(tagKeyOf));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
let tagKeysByEventType = {};
|
|
143
|
+
slices.forEach(s => {
|
|
144
|
+
let own = partitionBySlice[s.sliceName];
|
|
145
|
+
s.produced.forEach(e => {
|
|
146
|
+
let readKeys = Stdlib_Option.getOr(readKeysByEventType[e.eventType], []);
|
|
147
|
+
let indexed = e.idFields.map(tagKeyOf).filter(k => {
|
|
148
|
+
if (Primitive_object.equal(k, own)) {
|
|
149
|
+
return true;
|
|
150
|
+
} else {
|
|
151
|
+
return readKeys.includes(k);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
tagKeysByEventType[e.eventType] = dedupSorted(indexed);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
partitionBySlice: partitionBySlice,
|
|
159
|
+
ownerByKey: ownerByKey,
|
|
160
|
+
crossPartitionTagKeys: crossPartitionTagKeys,
|
|
161
|
+
tagKeysByEventType: tagKeysByEventType,
|
|
162
|
+
ambiguities: ambiguities
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export {
|
|
167
|
+
tagKeyOf,
|
|
168
|
+
dedupSorted,
|
|
169
|
+
keysOfEvent,
|
|
170
|
+
producedKeys,
|
|
171
|
+
consumedKeys,
|
|
172
|
+
commandScalarKeys,
|
|
173
|
+
foreignConsumedKeys,
|
|
174
|
+
crossPartitionForSlice,
|
|
175
|
+
infer,
|
|
176
|
+
}
|
|
177
|
+
/* No side effect */
|