@reventlessdev/reventless-local 3.0.0-alpha.167 → 3.0.0-alpha.169
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 +20 -0
- package/package.json +11 -11
- package/src/Platform.res +11 -1
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +16 -8
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +7 -7
- package/src/adapter/DomainGraphQL_Server.res +96 -0
- package/src/adapter/DomainGraphQL_Server.res.mjs +84 -0
- package/src/adapter/GraphQL_Server.res.mjs +18 -0
- package/src/adapter/LocalObjectStore.res +64 -0
- package/src/adapter/LocalObjectStore.res.mjs +55 -0
- package/src/adapter/PgProjectionCatchup.res +6 -1
- package/src/adapter/PgProjectionCatchup.res.mjs +4 -2
- package/src/adapter/ProjectionCheckpoint.res +11 -2
- package/src/adapter/ProjectionCheckpoint.res.mjs +9 -3
- package/tests/PluginEventDecodeTest.res +2 -0
- package/tests/PluginEventDecodeTest.res.mjs +2 -2
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +52 -1
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +39 -1
- package/tests/adapter/InboundTranslationMutationTest.res +139 -0
- package/tests/adapter/InboundTranslationMutationTest.res.mjs +132 -0
- package/tests/adapter/QueryDbListResolverTest.res +2 -0
- package/tests/adapter/QueryDbListResolverTest.res.mjs +2 -0
- package/tests/adapter/ServedBucketHttpTest.res +174 -0
- package/tests/adapter/ServedBucketHttpTest.res.mjs +177 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +13 -9
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +21 -13
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +215 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +273 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res +38 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res.mjs +28 -0
- package/tests/components/stateviewslice/StateViewSliceFixtures.res +1 -1
- package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +2 -1
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +1 -1
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +2 -1
|
@@ -180,16 +180,21 @@ let dcbCatchupEnvelope = (
|
|
|
180
180
|
~eventType: string,
|
|
181
181
|
~dataText: string,
|
|
182
182
|
~metaText: string,
|
|
183
|
+
~recordedAt: string,
|
|
183
184
|
~firstTagValue: option<string>,
|
|
184
185
|
): option<JSON.t> =>
|
|
185
186
|
switch (JSON.parseOrThrow(dataText), JSON.parseOrThrow(metaText)) {
|
|
186
187
|
| (data, meta) =>
|
|
187
188
|
let dataDict = data->JSON.Decode.object->Option.getOr(Dict.make())
|
|
188
189
|
let entityId = firstTagValue->Option.getOr(logName)
|
|
190
|
+
// The stored `recorded_at` column is the authoritative storage time; unlike
|
|
191
|
+
// live publish we must NOT re-stamp here (catch-up runs at startup, long
|
|
192
|
+
// after append). StateViewSlice projections read this as `consumed.recordedAt`.
|
|
189
193
|
Some(
|
|
190
194
|
Dict.fromArray([
|
|
191
195
|
("id", JSON.Encode.string(entityId)),
|
|
192
196
|
("meta", meta),
|
|
197
|
+
("recordedAt", JSON.Encode.string(recordedAt)),
|
|
193
198
|
("event", Message.combineMessage(eventType, dataDict)),
|
|
194
199
|
])->JSON.Encode.object,
|
|
195
200
|
)
|
|
@@ -226,7 +231,7 @@ let missedRows = (
|
|
|
226
231
|
// dcb_tag rows are inserted in tag order, so MIN(rowid) is the first tag.
|
|
227
232
|
db
|
|
228
233
|
->SqliteDriver.prepare(
|
|
229
|
-
"SELECT rowid AS pos, log_name, event_type, data, meta, (SELECT t.tag_value FROM dcb_tag t WHERE t.log_name = dcb_event.log_name AND t.position = dcb_event.position ORDER BY t.rowid ASC LIMIT 1) AS first_tag FROM dcb_event WHERE rowid > ? AND rowid <= ? ORDER BY rowid ASC",
|
|
234
|
+
"SELECT rowid AS pos, log_name, event_type, data, meta, recorded_at, (SELECT t.tag_value FROM dcb_tag t WHERE t.log_name = dcb_event.log_name AND t.position = dcb_event.position ORDER BY t.rowid ASC LIMIT 1) AS first_tag FROM dcb_event WHERE rowid > ? AND rowid <= ? ORDER BY rowid ASC",
|
|
230
235
|
)
|
|
231
236
|
->SqliteDriver.all([JSON.Encode.int(afterPos), JSON.Encode.int(upTo)])
|
|
232
237
|
->Array.filterMap(row =>
|
|
@@ -248,9 +253,13 @@ let missedRows = (
|
|
|
248
253
|
| Some(JSON.String(v)) => Some(v)
|
|
249
254
|
| _ => None
|
|
250
255
|
}
|
|
256
|
+
let recordedAt = switch row->Dict.get("recorded_at") {
|
|
257
|
+
| Some(JSON.String(v)) => v
|
|
258
|
+
| _ => ""
|
|
259
|
+
}
|
|
251
260
|
Some((
|
|
252
261
|
Float.toInt(pos),
|
|
253
|
-
dcbCatchupEnvelope(~logName, ~eventType, ~dataText, ~metaText, ~firstTagValue),
|
|
262
|
+
dcbCatchupEnvelope(~logName, ~eventType, ~dataText, ~metaText, ~recordedAt, ~firstTagValue),
|
|
254
263
|
))
|
|
255
264
|
| _ => None
|
|
256
265
|
}
|
|
@@ -129,7 +129,7 @@ function catchupEnvelope(flat) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
function dcbCatchupEnvelope(logName, eventType, dataText, metaText, firstTagValue) {
|
|
132
|
+
function dcbCatchupEnvelope(logName, eventType, dataText, metaText, recordedAt, firstTagValue) {
|
|
133
133
|
let val;
|
|
134
134
|
let val$1;
|
|
135
135
|
try {
|
|
@@ -149,6 +149,10 @@ function dcbCatchupEnvelope(logName, eventType, dataText, metaText, firstTagValu
|
|
|
149
149
|
"meta",
|
|
150
150
|
val$1
|
|
151
151
|
],
|
|
152
|
+
[
|
|
153
|
+
"recordedAt",
|
|
154
|
+
recordedAt
|
|
155
|
+
],
|
|
152
156
|
[
|
|
153
157
|
"event",
|
|
154
158
|
Message$ReventlessCore.combineMessage(eventType, dataDict)
|
|
@@ -191,7 +195,7 @@ function missedRows(db, axis, afterPos, upTo) {
|
|
|
191
195
|
];
|
|
192
196
|
});
|
|
193
197
|
} else {
|
|
194
|
-
return Stdlib_Array.filterMap(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, "SELECT rowid AS pos, log_name, event_type, data, meta, (SELECT t.tag_value FROM dcb_tag t WHERE t.log_name = dcb_event.log_name AND t.position = dcb_event.position ORDER BY t.rowid ASC LIMIT 1) AS first_tag FROM dcb_event WHERE rowid > ? AND rowid <= ? ORDER BY rowid ASC"), [
|
|
198
|
+
return Stdlib_Array.filterMap(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, "SELECT rowid AS pos, log_name, event_type, data, meta, recorded_at, (SELECT t.tag_value FROM dcb_tag t WHERE t.log_name = dcb_event.log_name AND t.position = dcb_event.position ORDER BY t.rowid ASC LIMIT 1) AS first_tag FROM dcb_event WHERE rowid > ? AND rowid <= ? ORDER BY rowid ASC"), [
|
|
195
199
|
afterPos,
|
|
196
200
|
upTo
|
|
197
201
|
]), row => {
|
|
@@ -232,9 +236,11 @@ function missedRows(db, axis, afterPos, upTo) {
|
|
|
232
236
|
}
|
|
233
237
|
let match$5 = row["first_tag"];
|
|
234
238
|
let firstTagValue = typeof match$5 === "string" ? match$5 : undefined;
|
|
239
|
+
let match$6 = row["recorded_at"];
|
|
240
|
+
let recordedAt = typeof match$6 === "string" ? match$6 : "";
|
|
235
241
|
return [
|
|
236
242
|
match | 0,
|
|
237
|
-
dcbCatchupEnvelope(match$1, match$2, match$3, match$4, firstTagValue)
|
|
243
|
+
dcbCatchupEnvelope(match$1, match$2, match$3, match$4, recordedAt, firstTagValue)
|
|
238
244
|
];
|
|
239
245
|
});
|
|
240
246
|
}
|
|
@@ -15,6 +15,7 @@ let envelopeOf = (event: ReventlessCore.PluginSpec.event) =>
|
|
|
15
15
|
ReventlessCore.Message.composeEventJson'(
|
|
16
16
|
"Catalog",
|
|
17
17
|
meta,
|
|
18
|
+
~recordedAt="2024-01-01T00:00:00Z",
|
|
18
19
|
event->S.reverseConvertToJsonOrThrow(ReventlessCore.PluginSpec.eventSchema),
|
|
19
20
|
)
|
|
20
21
|
|
|
@@ -68,6 +69,7 @@ describe("Platform.decodeUiFragmentRegistryEventEnvelope", () => {
|
|
|
68
69
|
ReventlessCore.Message.composeEventJson'(
|
|
69
70
|
"Catalog",
|
|
70
71
|
dcbMeta,
|
|
72
|
+
~recordedAt="2024-01-01T00:00:00Z",
|
|
71
73
|
ReventlessCore.Message.combineMessage(eventType, data),
|
|
72
74
|
)
|
|
73
75
|
}
|
|
@@ -15,7 +15,7 @@ TestRunner$ReventlessLocal.setup();
|
|
|
15
15
|
let meta = Message$ReventlessCore.generateMeta(PluginSpec$ReventlessCore.name, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
16
16
|
|
|
17
17
|
function envelopeOf(event) {
|
|
18
|
-
return Message$ReventlessCore.composeEventJson$p("Catalog", meta, S.reverseConvertToJsonOrThrow(event, PluginSpec$ReventlessCore.eventSchema));
|
|
18
|
+
return Message$ReventlessCore.composeEventJson$p("Catalog", meta, "2024-01-01T00:00:00Z", S.reverseConvertToJsonOrThrow(event, PluginSpec$ReventlessCore.eventSchema));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
globalThis.describe("Platform.decodePluginEventEnvelope", () => {
|
|
@@ -64,7 +64,7 @@ globalThis.describe("Platform.decodeUiFragmentRegistryEventEnvelope", () => {
|
|
|
64
64
|
let dcbEnvelopeOf = event => {
|
|
65
65
|
let json = S.reverseConvertToJsonOrThrow(event, UiFragmentRegistry$ReventlessCore.eventSchema);
|
|
66
66
|
let match = Message$ReventlessCore.splitMessage(json);
|
|
67
|
-
return Message$ReventlessCore.composeEventJson$p("Catalog", dcbMeta, Message$ReventlessCore.combineMessage(match[0], match[1]));
|
|
67
|
+
return Message$ReventlessCore.composeEventJson$p("Catalog", dcbMeta, "2024-01-01T00:00:00Z", Message$ReventlessCore.combineMessage(match[0], match[1]));
|
|
68
68
|
};
|
|
69
69
|
globalThis.test("decodes a UiFragmentRegistered event from the published envelope", () => {
|
|
70
70
|
let event = {
|
|
@@ -27,6 +27,21 @@ type svState = {
|
|
|
27
27
|
price: float,
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Multi-variant enum used both as a required and as an optional state field, to pin
|
|
31
|
+
// the nullability the sury→GraphQL deriver must preserve for the optional case.
|
|
32
|
+
@schema
|
|
33
|
+
type sourceKind =
|
|
34
|
+
| @as("tagged") Tagged
|
|
35
|
+
| @as("ancestry") Ancestry
|
|
36
|
+
| @as("substrate") Substrate
|
|
37
|
+
|
|
38
|
+
@schema
|
|
39
|
+
type enumState = {
|
|
40
|
+
productId: @s.matches(Reventless.DcbTag.string) string,
|
|
41
|
+
requiredSource: sourceKind,
|
|
42
|
+
optionalSource?: sourceKind,
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
@schema
|
|
31
46
|
type addCommand = {
|
|
32
47
|
productId: @s.matches(Reventless.DcbTag.string) string,
|
|
@@ -84,6 +99,8 @@ let indexedStateSchemaWithAnnotations = indexedStateSchema->S.Metadata.set(
|
|
|
84
99
|
collapsed: [],
|
|
85
100
|
scan: [],
|
|
86
101
|
scanSort: [],
|
|
102
|
+
semantic: [],
|
|
103
|
+
metric: [],
|
|
87
104
|
status: None,
|
|
88
105
|
groupBy: None,
|
|
89
106
|
visibility: None,
|
|
@@ -111,6 +128,8 @@ let orderedStateSchemaWithAnnotations = orderedStateSchema->S.Metadata.set(
|
|
|
111
128
|
collapsed: [],
|
|
112
129
|
scan: [],
|
|
113
130
|
scanSort: [],
|
|
131
|
+
semantic: [],
|
|
132
|
+
metric: [],
|
|
114
133
|
status: None,
|
|
115
134
|
groupBy: None,
|
|
116
135
|
visibility: None,
|
|
@@ -139,6 +158,8 @@ let scanStateSchemaWithAnnotations = scanStateSchema->S.Metadata.set(
|
|
|
139
158
|
collapsed: [],
|
|
140
159
|
scan: ["status"],
|
|
141
160
|
scanSort: ["name"],
|
|
161
|
+
semantic: [],
|
|
162
|
+
metric: [],
|
|
142
163
|
status: None,
|
|
143
164
|
groupBy: None,
|
|
144
165
|
visibility: None,
|
|
@@ -901,8 +922,38 @@ describe("Plugin admin fragment — kind enum + kindEq filter", () => {
|
|
|
901
922
|
// kind is a real enum on the Plugin type (auto-derived from the payload-less variant)
|
|
902
923
|
expect(sdl->String.includes("enum Platform_PluginKind"))->toBe(true)
|
|
903
924
|
expect(sdl->String.includes("PlatformInfrastructure"))->toBe(true)
|
|
904
|
-
|
|
925
|
+
// `kind` is `option<pluginKind>` — nullable on purpose so a kind-less legacy row
|
|
926
|
+
// can't collapse the whole Platform_Plugins query. It must render without a `!`.
|
|
927
|
+
expect(sdl->String.includes("kind: Platform_PluginKind"))->toBe(true)
|
|
928
|
+
expect(sdl->String.includes("kind: Platform_PluginKind!"))->toBe(false)
|
|
905
929
|
// @scan folds a server-side equality filter for the panel split
|
|
906
930
|
expect(sdl->String.includes("kindEq: String"))->toBe(true)
|
|
907
931
|
})
|
|
908
932
|
})
|
|
933
|
+
|
|
934
|
+
describe("optional enum fields preserve GraphQL nullability", () => {
|
|
935
|
+
testPromise(
|
|
936
|
+
"required variant field → SomeEnum!; optional variant field → nullable SomeEnum",
|
|
937
|
+
async () => {
|
|
938
|
+
let fragment = ReventlessCore.GraphQL_FragmentGenerator.generate(
|
|
939
|
+
~mutationEntries=[],
|
|
940
|
+
~queryEntries=[
|
|
941
|
+
{
|
|
942
|
+
singleFieldName: "SV_Sourced",
|
|
943
|
+
listFieldName: "SV_Sourceds",
|
|
944
|
+
returnTypeName: "EnumState",
|
|
945
|
+
stateSchema: enumStateSchema->S.castToUnknown,
|
|
946
|
+
authorization: None,
|
|
947
|
+
includeIdParam: false,
|
|
948
|
+
},
|
|
949
|
+
],
|
|
950
|
+
)
|
|
951
|
+
let sdl = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment).sdlPreview
|
|
952
|
+
// A required variant field stays non-null.
|
|
953
|
+
expect(sdl->String.includes("requiredSource: EnumStateRequiredSource!"))->toBe(true)
|
|
954
|
+
// An optional variant field is nullable: the enum is present but never with a `!`.
|
|
955
|
+
expect(sdl->String.includes("optionalSource: EnumStateOptionalSource"))->toBe(true)
|
|
956
|
+
expect(sdl->String.includes("optionalSource: EnumStateOptionalSource!"))->toBe(false)
|
|
957
|
+
},
|
|
958
|
+
)
|
|
959
|
+
})
|
|
@@ -29,6 +29,18 @@ let svStateSchema = S.schema(s => ({
|
|
|
29
29
|
price: s.m(S.float)
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
|
+
let sourceKindSchema = S.union([
|
|
33
|
+
S.literal("tagged"),
|
|
34
|
+
S.literal("ancestry"),
|
|
35
|
+
S.literal("substrate")
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
let enumStateSchema = S.schema(s => ({
|
|
39
|
+
productId: s.m(DcbTag$Reventless.string),
|
|
40
|
+
requiredSource: s.m(sourceKindSchema),
|
|
41
|
+
optionalSource: s.m(S.option(sourceKindSchema))
|
|
42
|
+
}));
|
|
43
|
+
|
|
32
44
|
let addCommandSchema = S.schema(s => ({
|
|
33
45
|
productId: s.m(DcbTag$Reventless.string),
|
|
34
46
|
name: s.m(S.string)
|
|
@@ -95,6 +107,8 @@ let indexedStateSchemaWithAnnotations = S.Metadata.set(indexedStateSchema, State
|
|
|
95
107
|
collapsed: [],
|
|
96
108
|
scan: [],
|
|
97
109
|
scanSort: [],
|
|
110
|
+
semantic: [],
|
|
111
|
+
metric: [],
|
|
98
112
|
status: undefined,
|
|
99
113
|
groupBy: undefined,
|
|
100
114
|
visibility: undefined
|
|
@@ -119,6 +133,8 @@ let orderedStateSchemaWithAnnotations = S.Metadata.set(orderedStateSchema, State
|
|
|
119
133
|
collapsed: [],
|
|
120
134
|
scan: [],
|
|
121
135
|
scanSort: [],
|
|
136
|
+
semantic: [],
|
|
137
|
+
metric: [],
|
|
122
138
|
status: undefined,
|
|
123
139
|
groupBy: undefined,
|
|
124
140
|
visibility: undefined
|
|
@@ -144,6 +160,8 @@ let scanStateSchemaWithAnnotations = S.Metadata.set(scanStateSchema, StateAnnota
|
|
|
144
160
|
collapsed: [],
|
|
145
161
|
scan: ["status"],
|
|
146
162
|
scanSort: ["name"],
|
|
163
|
+
semantic: [],
|
|
164
|
+
metric: [],
|
|
147
165
|
status: undefined,
|
|
148
166
|
groupBy: undefined,
|
|
149
167
|
visibility: undefined
|
|
@@ -635,11 +653,29 @@ globalThis.describe("Plugin admin fragment — kind enum + kindEq filter", () =>
|
|
|
635
653
|
let sdl = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment).sdlPreview;
|
|
636
654
|
globalThis.expect(sdl.includes("enum Platform_PluginKind")).toBe(true);
|
|
637
655
|
globalThis.expect(sdl.includes("PlatformInfrastructure")).toBe(true);
|
|
638
|
-
globalThis.expect(sdl.includes("kind: Platform_PluginKind
|
|
656
|
+
globalThis.expect(sdl.includes("kind: Platform_PluginKind")).toBe(true);
|
|
657
|
+
globalThis.expect(sdl.includes("kind: Platform_PluginKind!")).toBe(false);
|
|
639
658
|
globalThis.expect(sdl.includes("kindEq: String")).toBe(true);
|
|
640
659
|
});
|
|
641
660
|
});
|
|
642
661
|
|
|
662
|
+
globalThis.describe("optional enum fields preserve GraphQL nullability", () => {
|
|
663
|
+
globalThis.test("required variant field → SomeEnum!; optional variant field → nullable SomeEnum", async () => {
|
|
664
|
+
let fragment = GraphQL_FragmentGenerator$ReventlessCore.generate([], [{
|
|
665
|
+
singleFieldName: "SV_Sourced",
|
|
666
|
+
listFieldName: "SV_Sourceds",
|
|
667
|
+
returnTypeName: "EnumState",
|
|
668
|
+
stateSchema: enumStateSchema,
|
|
669
|
+
authorization: undefined,
|
|
670
|
+
includeIdParam: false
|
|
671
|
+
}]);
|
|
672
|
+
let sdl = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment).sdlPreview;
|
|
673
|
+
globalThis.expect(sdl.includes("requiredSource: EnumStateRequiredSource!")).toBe(true);
|
|
674
|
+
globalThis.expect(sdl.includes("optionalSource: EnumStateOptionalSource")).toBe(true);
|
|
675
|
+
globalThis.expect(sdl.includes("optionalSource: EnumStateOptionalSource!")).toBe(false);
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
643
679
|
let stringSchema = S.string;
|
|
644
680
|
|
|
645
681
|
let taggedSchema = DcbTag$Reventless.string;
|
|
@@ -655,6 +691,8 @@ export {
|
|
|
655
691
|
boolSchema,
|
|
656
692
|
testStateSchema,
|
|
657
693
|
svStateSchema,
|
|
694
|
+
sourceKindSchema,
|
|
695
|
+
enumStateSchema,
|
|
658
696
|
addCommandSchema,
|
|
659
697
|
unionCommandSchema,
|
|
660
698
|
dcbSingleCommandSchema,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Response-envelope tests for InboundTranslationSlice mutations.
|
|
2
|
+
//
|
|
3
|
+
// The field is declared `CommandResult!`, an abstract type. A resolver invoked
|
|
4
|
+
// directly cannot show whether the value it returns actually resolves to a union
|
|
5
|
+
// member — only a real execution can, and until one existed every inbound
|
|
6
|
+
// mutation came back as `Abstract type "CommandResult" must resolve to an Object
|
|
7
|
+
// type at runtime` even though the translation had succeeded.
|
|
8
|
+
//
|
|
9
|
+
// So these tests compose the schema the way the running platform does
|
|
10
|
+
// (DomainGraphQL_Server.composeSchema) and execute a document against it, then
|
|
11
|
+
// assert on the FULL envelope — `errors` as well as `data`.
|
|
12
|
+
//
|
|
13
|
+
// The resolver is driven by a real InboundTranslationSlice_Callback, so the
|
|
14
|
+
// mutation exercises parse -> translate -> publish -> encode end to end.
|
|
15
|
+
|
|
16
|
+
@@warning("-44")
|
|
17
|
+
|
|
18
|
+
open JestGlobals
|
|
19
|
+
open InboundTranslationSliceFixtures
|
|
20
|
+
|
|
21
|
+
module PaymentWebhookTranslation = {
|
|
22
|
+
let translate = PaymentWebhookSpec.translate
|
|
23
|
+
let moduleUrl = PaymentWebhookSpec.moduleUrl
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module Callback = ReventlessCore.InboundTranslationSlice_Callback.Make(
|
|
27
|
+
PaymentWebhookSpec,
|
|
28
|
+
PaymentWebhookTranslation,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Registered inside a plugin scope, not the platform one: a plugin bucket's
|
|
32
|
+
// document is validated standalone before the cross-plugin merge, so this also
|
|
33
|
+
// pins that the inbound path registers the CommandResult union itself rather
|
|
34
|
+
// than borrowing a registration from a sibling command handler.
|
|
35
|
+
let scope = "Payments"
|
|
36
|
+
let fieldName = "Payments_PaymentWebhook"
|
|
37
|
+
|
|
38
|
+
let published: ref<array<Reventless.Message.commandJson>> = ref([])
|
|
39
|
+
|
|
40
|
+
let publishJsons: ReventlessInfra.CommandTopic.publishJsons = async cmds =>
|
|
41
|
+
published.contents = published.contents->Array.concat(cmds)
|
|
42
|
+
|
|
43
|
+
let selection = `__typename
|
|
44
|
+
... on CommandAccepted { msgId entityId eventCount }
|
|
45
|
+
... on CommandRejected { msgId errorCode errorDetail }`
|
|
46
|
+
|
|
47
|
+
let runMutation = async (~status: string) => {
|
|
48
|
+
let source = `mutation {
|
|
49
|
+
r: ${fieldName}(paymentId: "pay-1", orderId: "ord-1", status: "${status}") { ${selection} }
|
|
50
|
+
}`
|
|
51
|
+
await GraphqlYoga.graphql({
|
|
52
|
+
"schema": DomainGraphQL_Server.composeSchema(),
|
|
53
|
+
"source": source,
|
|
54
|
+
"contextValue": JSON.Encode.null,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let errorMessages = (result: GraphqlYoga.executionResult): array<string> =>
|
|
59
|
+
result.errors
|
|
60
|
+
->Option.getOr([])
|
|
61
|
+
->Array.map(e => e->JsExn.message->Option.getOr("unknown error"))
|
|
62
|
+
|
|
63
|
+
let payload = (result: GraphqlYoga.executionResult): JSON.t =>
|
|
64
|
+
result.data
|
|
65
|
+
->Option.getOr(JSON.Encode.null)
|
|
66
|
+
->JSON.Decode.object
|
|
67
|
+
->Option.flatMap(d => d->Dict.get("r"))
|
|
68
|
+
->Option.getOr(JSON.Encode.null)
|
|
69
|
+
|
|
70
|
+
let str = (node: JSON.t, key: string): option<string> =>
|
|
71
|
+
node->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))->Option.flatMap(JSON.Decode.string)
|
|
72
|
+
|
|
73
|
+
let num = (node: JSON.t, key: string): option<float> =>
|
|
74
|
+
node->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))->Option.flatMap(JSON.Decode.float)
|
|
75
|
+
|
|
76
|
+
describe("InboundTranslationSlice mutation — response envelope", () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
DomainGraphQL_Server.asInterface.reset()
|
|
79
|
+
published.contents = []
|
|
80
|
+
Callback.auditLog->Dict.keysToArray->Array.forEach(k => Callback.auditLog->Dict.delete(k))
|
|
81
|
+
|
|
82
|
+
DomainGraphQL_Server.setScope(scope)
|
|
83
|
+
InboundTranslationResolvers_GraphQL.register(
|
|
84
|
+
~fieldName,
|
|
85
|
+
~externalInputSchema=PaymentWebhookSpec.externalInputSchema->S.castToUnknown,
|
|
86
|
+
~server=DomainGraphQL_Server.asInterface,
|
|
87
|
+
)
|
|
88
|
+
DomainGraphQL_Server.resetScope()
|
|
89
|
+
|
|
90
|
+
InboundTranslationResolvers_GraphQL.bindReceive(
|
|
91
|
+
~fieldName,
|
|
92
|
+
~receive=inputJson => Callback.receive(publishJsons, inputJson),
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
testPromise("a successful translation resolves as CommandAccepted, with no errors", async () => {
|
|
97
|
+
let result = await runMutation(~status="completed")
|
|
98
|
+
|
|
99
|
+
expect(errorMessages(result))->toEqual([])
|
|
100
|
+
|
|
101
|
+
let node = payload(result)
|
|
102
|
+
expect(node->str("__typename"))->toEqual(Some("CommandAccepted"))
|
|
103
|
+
expect(node->str("entityId"))->toEqual(Some("ord-1"))
|
|
104
|
+
expect(node->num("eventCount"))->toEqual(Some(1.0))
|
|
105
|
+
|
|
106
|
+
// msgId is the audit-row key, so the caller can look the request up.
|
|
107
|
+
let msgId = node->str("msgId")->Option.getOr("")
|
|
108
|
+
expect(Callback.auditLog->Dict.get(msgId)->Option.isSome)->toBe(true)
|
|
109
|
+
|
|
110
|
+
expect(published.contents->Array.length)->toBe(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
testPromise("a rejected translation resolves as CommandRejected, with no errors", async () => {
|
|
114
|
+
let result = await runMutation(~status="pending")
|
|
115
|
+
|
|
116
|
+
expect(errorMessages(result))->toEqual([])
|
|
117
|
+
|
|
118
|
+
let node = payload(result)
|
|
119
|
+
expect(node->str("__typename"))->toEqual(Some("CommandRejected"))
|
|
120
|
+
expect(node->str("errorCode"))->toEqual(Some("TranslationFailed"))
|
|
121
|
+
expect(node->str("errorDetail"))->toEqual(Some("Unknown payment status: pending"))
|
|
122
|
+
|
|
123
|
+
let msgId = node->str("msgId")->Option.getOr("")
|
|
124
|
+
switch Callback.auditLog->Dict.get(msgId) {
|
|
125
|
+
| Some(row) => expect(row.status)->toBe(ReventlessCore.InboundTranslationSlice_Callback.Failure)
|
|
126
|
+
| None => expect(true)->toBe(false)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
expect(published.contents->Array.length)->toBe(0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
testSync("the mutation field is typed CommandResult!", () => {
|
|
133
|
+
let fieldLine =
|
|
134
|
+
DomainGraphQL_Server.asInterface.buildSdl()
|
|
135
|
+
->String.split("\n")
|
|
136
|
+
->Array.find(line => line->String.includes(fieldName))
|
|
137
|
+
expect(fieldLine->Option.map(l => l->String.endsWith(": CommandResult!")))->toEqual(Some(true))
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Graphql from "graphql";
|
|
4
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
5
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
6
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
|
+
import * as DomainGraphQL_Server$ReventlessLocal from "../../src/adapter/DomainGraphQL_Server.res.mjs";
|
|
9
|
+
import * as InboundTranslationSliceFixtures$ReventlessLocal from "../components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs";
|
|
10
|
+
import * as InboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs";
|
|
11
|
+
import * as InboundTranslationResolvers_GraphQL$ReventlessLocal from "../../src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs";
|
|
12
|
+
|
|
13
|
+
let translate = InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.translate;
|
|
14
|
+
|
|
15
|
+
let moduleUrl = InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.moduleUrl;
|
|
16
|
+
|
|
17
|
+
let PaymentWebhookTranslation = {
|
|
18
|
+
translate: translate,
|
|
19
|
+
moduleUrl: moduleUrl
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
23
|
+
name: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.name,
|
|
24
|
+
moduleUrl: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.moduleUrl,
|
|
25
|
+
externalInputSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema,
|
|
26
|
+
commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
|
|
27
|
+
targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
|
|
28
|
+
externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
|
|
29
|
+
commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandAuthorization
|
|
30
|
+
})(PaymentWebhookTranslation);
|
|
31
|
+
|
|
32
|
+
let scope = "Payments";
|
|
33
|
+
|
|
34
|
+
let fieldName = "Payments_PaymentWebhook";
|
|
35
|
+
|
|
36
|
+
let published = {
|
|
37
|
+
contents: []
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function publishJsons(cmds) {
|
|
41
|
+
published.contents = published.contents.concat(cmds);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let selection = `__typename
|
|
45
|
+
... on CommandAccepted { msgId entityId eventCount }
|
|
46
|
+
... on CommandRejected { msgId errorCode errorDetail }`;
|
|
47
|
+
|
|
48
|
+
async function runMutation(status) {
|
|
49
|
+
let source = `mutation {
|
|
50
|
+
r: ` + fieldName + `(paymentId: "pay-1", orderId: "ord-1", status: "` + status + `") { ` + selection + ` }
|
|
51
|
+
}`;
|
|
52
|
+
return await Graphql.graphql({
|
|
53
|
+
schema: DomainGraphQL_Server$ReventlessLocal.composeSchema(),
|
|
54
|
+
source: source,
|
|
55
|
+
contextValue: null
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function errorMessages(result) {
|
|
60
|
+
return Stdlib_Option.getOr(result.errors, []).map(e => Stdlib_Option.getOr(Stdlib_JsExn.message(e), "unknown error"));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function payload(result) {
|
|
64
|
+
return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(Stdlib_Option.getOr(result.data, null)), d => d["r"]), null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function str(node, key) {
|
|
68
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(node), d => d[key]), Stdlib_JSON.Decode.string);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function num(node, key) {
|
|
72
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(node), d => d[key]), Stdlib_JSON.Decode.float);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
globalThis.describe("InboundTranslationSlice mutation — response envelope", () => {
|
|
76
|
+
globalThis.beforeEach(() => {
|
|
77
|
+
DomainGraphQL_Server$ReventlessLocal.asInterface.reset();
|
|
78
|
+
published.contents = [];
|
|
79
|
+
Object.keys(Callback.auditLog).forEach(k => Stdlib_Dict.$$delete(Callback.auditLog, k));
|
|
80
|
+
DomainGraphQL_Server$ReventlessLocal.setScope(scope);
|
|
81
|
+
InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema, DomainGraphQL_Server$ReventlessLocal.asInterface);
|
|
82
|
+
DomainGraphQL_Server$ReventlessLocal.resetScope();
|
|
83
|
+
InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, inputJson => Callback.receive(publishJsons, inputJson));
|
|
84
|
+
});
|
|
85
|
+
globalThis.test("a successful translation resolves as CommandAccepted, with no errors", async () => {
|
|
86
|
+
let result = await runMutation("completed");
|
|
87
|
+
globalThis.expect(errorMessages(result)).toEqual([]);
|
|
88
|
+
let node = payload(result);
|
|
89
|
+
globalThis.expect(str(node, "__typename")).toEqual("CommandAccepted");
|
|
90
|
+
globalThis.expect(str(node, "entityId")).toEqual("ord-1");
|
|
91
|
+
globalThis.expect(num(node, "eventCount")).toEqual(1.0);
|
|
92
|
+
let msgId = Stdlib_Option.getOr(str(node, "msgId"), "");
|
|
93
|
+
globalThis.expect(Stdlib_Option.isSome(Callback.auditLog[msgId])).toBe(true);
|
|
94
|
+
globalThis.expect(published.contents.length).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
globalThis.test("a rejected translation resolves as CommandRejected, with no errors", async () => {
|
|
97
|
+
let result = await runMutation("pending");
|
|
98
|
+
globalThis.expect(errorMessages(result)).toEqual([]);
|
|
99
|
+
let node = payload(result);
|
|
100
|
+
globalThis.expect(str(node, "__typename")).toEqual("CommandRejected");
|
|
101
|
+
globalThis.expect(str(node, "errorCode")).toEqual("TranslationFailed");
|
|
102
|
+
globalThis.expect(str(node, "errorDetail")).toEqual("Unknown payment status: pending");
|
|
103
|
+
let msgId = Stdlib_Option.getOr(str(node, "msgId"), "");
|
|
104
|
+
let row = Callback.auditLog[msgId];
|
|
105
|
+
if (row !== undefined) {
|
|
106
|
+
globalThis.expect(row.status).toBe("Failure");
|
|
107
|
+
} else {
|
|
108
|
+
globalThis.expect(true).toBe(false);
|
|
109
|
+
}
|
|
110
|
+
globalThis.expect(published.contents.length).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
globalThis.test("the mutation field is typed CommandResult!", () => {
|
|
113
|
+
let fieldLine = DomainGraphQL_Server$ReventlessLocal.asInterface.buildSdl().split("\n").find(line => line.includes(fieldName));
|
|
114
|
+
globalThis.expect(Stdlib_Option.map(fieldLine, l => l.endsWith(": CommandResult!"))).toEqual(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export {
|
|
119
|
+
PaymentWebhookTranslation,
|
|
120
|
+
Callback,
|
|
121
|
+
scope,
|
|
122
|
+
fieldName,
|
|
123
|
+
published,
|
|
124
|
+
publishJsons,
|
|
125
|
+
selection,
|
|
126
|
+
runMutation,
|
|
127
|
+
errorMessages,
|
|
128
|
+
payload,
|
|
129
|
+
str,
|
|
130
|
+
num,
|
|
131
|
+
}
|
|
132
|
+
/* Callback Not a pure module */
|