@reventlessdev/reventless-local 3.0.0-alpha.166 → 3.0.0-alpha.168

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +11 -11
  3. package/src/Platform.res +11 -1
  4. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +16 -8
  5. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +7 -7
  6. package/src/adapter/DomainGraphQL_Server.res +96 -0
  7. package/src/adapter/DomainGraphQL_Server.res.mjs +84 -0
  8. package/src/adapter/GraphQL_Server.res.mjs +18 -0
  9. package/src/adapter/LocalObjectStore.res +64 -0
  10. package/src/adapter/LocalObjectStore.res.mjs +55 -0
  11. package/src/adapter/PgProjectionCatchup.res +6 -1
  12. package/src/adapter/PgProjectionCatchup.res.mjs +4 -2
  13. package/src/adapter/ProjectionCheckpoint.res +11 -2
  14. package/src/adapter/ProjectionCheckpoint.res.mjs +9 -3
  15. package/tests/PluginEventDecodeTest.res +2 -0
  16. package/tests/PluginEventDecodeTest.res.mjs +2 -2
  17. package/tests/adapter/GraphQL_SchemaInspectorTest.res +6 -0
  18. package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +6 -0
  19. package/tests/adapter/InboundTranslationMutationTest.res +139 -0
  20. package/tests/adapter/InboundTranslationMutationTest.res.mjs +132 -0
  21. package/tests/adapter/QueryDbListResolverTest.res +2 -0
  22. package/tests/adapter/QueryDbListResolverTest.res.mjs +2 -0
  23. package/tests/adapter/ServedBucketHttpTest.res +174 -0
  24. package/tests/adapter/ServedBucketHttpTest.res.mjs +177 -0
  25. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +13 -9
  26. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +21 -13
  27. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +215 -0
  28. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +273 -0
  29. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res +38 -0
  30. package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res.mjs +28 -0
  31. package/tests/components/stateviewslice/StateViewSliceFixtures.res +1 -1
  32. package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +2 -1
  33. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +1 -1
  34. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +2 -1
@@ -77,7 +77,7 @@ async function runCatchup(pool, bounds, handlers) {
77
77
  }
78
78
  }
79
79
  }
80
- let dcbRows = await PgDriver$ReventlessPostgres.query(pool, "SELECT log_name, event_type, data, meta, tags FROM dcb_event WHERE position <= $1::bigint ORDER BY position ASC", [bounds.dcbBound]);
80
+ let dcbRows = await PgDriver$ReventlessPostgres.query(pool, "SELECT log_name, event_type, data, meta, tags, recorded_at FROM dcb_event WHERE position <= $1::bigint ORDER BY position ASC", [bounds.dcbBound]);
81
81
  for (let i$1 = 0, i_finish$1 = dcbRows.length; i$1 < i_finish$1; ++i$1) {
82
82
  let row$1 = dcbRows[i$1];
83
83
  let match = row$1["log_name"];
@@ -85,7 +85,9 @@ async function runCatchup(pool, bounds, handlers) {
85
85
  let match$2 = row$1["data"];
86
86
  let match$3 = row$1["meta"];
87
87
  if (typeof match === "string" && typeof match$1 === "string" && match$2 !== undefined && match$3 !== undefined) {
88
- let envelope$1 = ProjectionCheckpoint$ReventlessLocal.dcbCatchupEnvelope(match, match$1, JSON.stringify(match$2), JSON.stringify(match$3), firstTagValue(row$1));
88
+ let match$4 = row$1["recorded_at"];
89
+ let recordedAt = typeof match$4 === "string" ? match$4 : "";
90
+ let envelope$1 = ProjectionCheckpoint$ReventlessLocal.dcbCatchupEnvelope(match, match$1, JSON.stringify(match$2), JSON.stringify(match$3), recordedAt, firstTagValue(row$1));
89
91
  if (envelope$1 !== undefined) {
90
92
  await deliver(handlers, envelope$1);
91
93
  }
@@ -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 = {
@@ -84,6 +84,8 @@ let indexedStateSchemaWithAnnotations = indexedStateSchema->S.Metadata.set(
84
84
  collapsed: [],
85
85
  scan: [],
86
86
  scanSort: [],
87
+ semantic: [],
88
+ metric: [],
87
89
  status: None,
88
90
  groupBy: None,
89
91
  visibility: None,
@@ -111,6 +113,8 @@ let orderedStateSchemaWithAnnotations = orderedStateSchema->S.Metadata.set(
111
113
  collapsed: [],
112
114
  scan: [],
113
115
  scanSort: [],
116
+ semantic: [],
117
+ metric: [],
114
118
  status: None,
115
119
  groupBy: None,
116
120
  visibility: None,
@@ -139,6 +143,8 @@ let scanStateSchemaWithAnnotations = scanStateSchema->S.Metadata.set(
139
143
  collapsed: [],
140
144
  scan: ["status"],
141
145
  scanSort: ["name"],
146
+ semantic: [],
147
+ metric: [],
142
148
  status: None,
143
149
  groupBy: None,
144
150
  visibility: None,
@@ -95,6 +95,8 @@ let indexedStateSchemaWithAnnotations = S.Metadata.set(indexedStateSchema, State
95
95
  collapsed: [],
96
96
  scan: [],
97
97
  scanSort: [],
98
+ semantic: [],
99
+ metric: [],
98
100
  status: undefined,
99
101
  groupBy: undefined,
100
102
  visibility: undefined
@@ -119,6 +121,8 @@ let orderedStateSchemaWithAnnotations = S.Metadata.set(orderedStateSchema, State
119
121
  collapsed: [],
120
122
  scan: [],
121
123
  scanSort: [],
124
+ semantic: [],
125
+ metric: [],
122
126
  status: undefined,
123
127
  groupBy: undefined,
124
128
  visibility: undefined
@@ -144,6 +148,8 @@ let scanStateSchemaWithAnnotations = S.Metadata.set(scanStateSchema, StateAnnota
144
148
  collapsed: [],
145
149
  scan: ["status"],
146
150
  scanSort: ["name"],
151
+ semantic: [],
152
+ metric: [],
147
153
  status: undefined,
148
154
  groupBy: undefined,
149
155
  visibility: undefined
@@ -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 */
@@ -35,6 +35,8 @@ let rowStateSchemaWithAnnotations =
35
35
  collapsed: [],
36
36
  scan: ["status"],
37
37
  scanSort: ["name"],
38
+ semantic: [],
39
+ metric: [],
38
40
  status: None,
39
41
  groupBy: None,
40
42
  visibility: None,
@@ -43,6 +43,8 @@ let rowStateSchemaWithAnnotations = S.Metadata.set(rowStateSchema, StateAnnotati
43
43
  collapsed: [],
44
44
  scan: ["status"],
45
45
  scanSort: ["name"],
46
+ semantic: [],
47
+ metric: [],
46
48
  status: undefined,
47
49
  groupBy: undefined,
48
50
  visibility: undefined
@@ -0,0 +1,174 @@
1
+ // End-to-end test for the local served-bucket HTTP routes on DomainGraphQL_Server
2
+ // (the dev analogue of the AWS CloudFront read path). Boots a real server on a
3
+ // private port, then exercises the full upload → store → serve loop:
4
+ // POST /__inmemory/upload → {uploadUrl, storageRef}
5
+ // PUT /{prefix}/{key} → 200 (store)
6
+ // GET /{prefix}/{key} → the stored bytes + content-type
7
+ // Talks to the server over node:http (Jest 27's VM strips global fetch), same
8
+ // as LocalAuthHttpTest.
9
+
10
+ @@warning("-44")
11
+
12
+ open JestGlobals
13
+
14
+ let _ = TestRunner.setup()
15
+
16
+ // High ephemeral port, clear of dev-server defaults and the auth HTTP test.
17
+ let port = 49322
18
+
19
+ // ── Minimal node:http helpers ───────────────────────────────────────────────
20
+
21
+ type httpRequestOptions = {
22
+ hostname: string,
23
+ port: int,
24
+ path: string,
25
+ method: string,
26
+ headers: dict<string>,
27
+ }
28
+ type httpReq
29
+ type httpRes
30
+ @module("node:http") external _request: (httpRequestOptions, httpRes => unit) => httpReq = "request"
31
+ @send external _reqWrite: (httpReq, string) => unit = "write"
32
+ @send external _reqEnd: (httpReq, @as(json`null`) _) => unit = "end"
33
+ @send external _reqOnError: (httpReq, @as("error") _, 'err => unit) => unit = "on"
34
+ @send external _resOnData: (httpRes, @as("data") _, 'chunk => unit) => unit = "on"
35
+ @send external _resOnEnd: (httpRes, @as("end") _, unit => unit) => unit = "on"
36
+ @send external _resSetEncoding: (httpRes, string) => unit = "setEncoding"
37
+ @get external _resStatusCode: httpRes => int = "statusCode"
38
+ @get external _resHeaders: httpRes => dict<string> = "headers"
39
+
40
+ let postJson = (path: string, body: dict<JSON.t>): promise<(int, JSON.t)> =>
41
+ Promise.make((resolve, reject) => {
42
+ let bodyStr = body->JSON.Encode.object->JSON.stringify
43
+ let req =
44
+ _request(
45
+ {
46
+ hostname: "localhost",
47
+ port,
48
+ path,
49
+ method: "POST",
50
+ headers: Dict.fromArray([
51
+ ("content-type", "application/json"),
52
+ ("content-length", bodyStr->String.length->Int.toString),
53
+ ]),
54
+ },
55
+ res => {
56
+ let status = _resStatusCode(res)
57
+ let buf = ref("")
58
+ res->_resSetEncoding("utf8")
59
+ res->_resOnData(chunk => buf := buf.contents ++ Obj.magic(chunk))
60
+ res->_resOnEnd(() => {
61
+ let parsed = try buf.contents->JSON.parseOrThrow catch {
62
+ | _ => JSON.Encode.null
63
+ }
64
+ resolve((status, parsed))
65
+ })
66
+ },
67
+ )
68
+ req->_reqOnError(err => reject(Obj.magic(err)))
69
+ req->_reqWrite(bodyStr)
70
+ req->_reqEnd
71
+ })
72
+
73
+ // PUT a raw (ASCII) body to a served-object path. Content-length is byte-safe
74
+ // only for ASCII payloads, which the SVG fixture below is.
75
+ let putRaw = (path: string, body: string, ~contentType: string): promise<int> =>
76
+ Promise.make((resolve, reject) => {
77
+ let req =
78
+ _request(
79
+ {
80
+ hostname: "localhost",
81
+ port,
82
+ path,
83
+ method: "PUT",
84
+ headers: Dict.fromArray([
85
+ ("content-type", contentType),
86
+ ("content-length", body->String.length->Int.toString),
87
+ ]),
88
+ },
89
+ res => {
90
+ let status = _resStatusCode(res)
91
+ res->_resOnData(_ => ())
92
+ res->_resOnEnd(() => resolve(status))
93
+ },
94
+ )
95
+ req->_reqOnError(err => reject(Obj.magic(err)))
96
+ req->_reqWrite(body)
97
+ req->_reqEnd
98
+ })
99
+
100
+ let getRaw = (path: string): promise<(int, string, string)> =>
101
+ Promise.make((resolve, reject) => {
102
+ let req =
103
+ _request(
104
+ {hostname: "localhost", port, path, method: "GET", headers: Dict.make()},
105
+ res => {
106
+ let status = _resStatusCode(res)
107
+ let contentType = _resHeaders(res)->Dict.get("content-type")->Option.getOr("")
108
+ let buf = ref("")
109
+ res->_resSetEncoding("utf8")
110
+ res->_resOnData(chunk => buf := buf.contents ++ Obj.magic(chunk))
111
+ res->_resOnEnd(() => resolve((status, buf.contents, contentType)))
112
+ },
113
+ )
114
+ req->_reqOnError(err => reject(Obj.magic(err)))
115
+ req->_reqEnd
116
+ })
117
+
118
+ let getString = (j: JSON.t, k: string): string =>
119
+ j->JSON.Decode.object->Option.flatMap(d => d->Dict.get(k))->Option.flatMap(JSON.Decode.string)->Option.getOr("")
120
+
121
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
122
+
123
+ beforeAllAsync(async () => {
124
+ DomainGraphQL_Server.reset()
125
+ DomainGraphQL_Server.start(~port, ())
126
+ await Promise.make((resolve, _) => setTimeout(() => resolve(), 50)->ignore)
127
+ })
128
+
129
+ afterAll(() => {
130
+ DomainGraphQL_Server.stop()
131
+ })
132
+
133
+ // ── Tests ──────────────────────────────────────────────────────────────────
134
+
135
+ testPromise("POST /__inmemory/upload returns matching uploadUrl + storageRef under the prefix", async () => {
136
+ let (status, body) =
137
+ await postJson(
138
+ "/__inmemory/upload",
139
+ Dict.fromArray([
140
+ ("fileName", JSON.Encode.string("logo.svg")),
141
+ ("contentType", JSON.Encode.string("image/svg+xml")),
142
+ ]),
143
+ )
144
+ expect(status)->toEqual(200)
145
+ let uploadUrl = getString(body, "uploadUrl")
146
+ let storageRef = getString(body, "storageRef")
147
+ // Same same-origin ref serves as both the PUT target and the stored value.
148
+ expect(uploadUrl)->toEqual(storageRef)
149
+ expect(storageRef->String.startsWith("/uploads/"))->toEqual(true)
150
+ expect(storageRef->String.endsWith("/logo.svg"))->toEqual(true)
151
+ })
152
+
153
+ testPromise("PUT then GET round-trips the bytes and content-type", async () => {
154
+ let (_, body) =
155
+ await postJson(
156
+ "/__inmemory/upload",
157
+ Dict.fromArray([("fileName", JSON.Encode.string("pixel.svg"))]),
158
+ )
159
+ let ref = getString(body, "storageRef")
160
+ let svg = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'></svg>"
161
+
162
+ let putStatus = await putRaw(ref, svg, ~contentType="image/svg+xml")
163
+ expect(putStatus)->toEqual(200)
164
+
165
+ let (getStatus, gotBody, gotContentType) = await getRaw(ref)
166
+ expect(getStatus)->toEqual(200)
167
+ expect(gotBody)->toEqual(svg)
168
+ expect(gotContentType)->toEqual("image/svg+xml")
169
+ })
170
+
171
+ testPromise("GET a missing served object returns 404", async () => {
172
+ let (status, _, _) = await getRaw("/uploads/missing/none.svg")
173
+ expect(status)->toEqual(404)
174
+ })