@reventlessdev/reventless-core 3.0.0-alpha.134 → 3.0.0-alpha.136

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 (36) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +8 -8
  3. package/src/Projection.res +19 -10
  4. package/src/Projection.res.mjs +17 -9
  5. package/src/components/Aggregate/Aggregate_Callback.res +6 -3
  6. package/src/components/Aggregate/Aggregate_Callback.res.mjs +5 -4
  7. package/src/components/EventLog/EventLog.res +13 -1
  8. package/src/components/EventLog/EventLog_Operations.res +37 -34
  9. package/src/components/EventLog/EventLog_Operations.res.mjs +30 -13
  10. package/src/components/ReadModel/ReadModel.res +5 -0
  11. package/src/components/ReadModel/ReadModel_Builder.res +12 -0
  12. package/src/components/ReadModel/ReadModel_Builder.res.mjs +3 -0
  13. package/src/plugin/component/Plugin_Structure.res +9 -2
  14. package/src/plugin/component/Plugin_Structure.res.mjs +7 -4
  15. package/src/plugin/component/SchemaWalker.res +13 -1
  16. package/src/plugin/component/SchemaWalker.res.mjs +5 -4
  17. package/src/util/CommandPublisher.res +20 -10
  18. package/src/util/CommandPublisher.res.mjs +6 -10
  19. package/src/util/Util_Array.res +1 -1
  20. package/src/util/Util_Array.res.mjs +1 -1
  21. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +2 -1
  22. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +4 -2
  23. package/tests/aggregate/AggregateConflictTest.res +3 -3
  24. package/tests/aggregate/AggregateConflictTest.res.mjs +1 -1
  25. package/tests/aggregate/AggregateFixtures.res +3 -3
  26. package/tests/aggregate/AggregateFixtures.res.mjs +4 -1
  27. package/tests/eventlog/EventLogFixtures.res +2 -2
  28. package/tests/eventlog/EventLogFixtures.res.mjs +8 -2
  29. package/tests/eventlog/EventLogRetryTest.res +12 -11
  30. package/tests/eventlog/EventLogRetryTest.res.mjs +31 -7
  31. package/tests/plugin/ManifestVisibilityTest.res +14 -0
  32. package/tests/plugin/ManifestVisibilityTest.res.mjs +10 -0
  33. package/tests/projection/ProjectionOptimizeTest.res +38 -0
  34. package/tests/projection/ProjectionOptimizeTest.res.mjs +75 -0
  35. package/tests/util/CommandPublisherTest.res +56 -0
  36. package/tests/util/CommandPublisherTest.res.mjs +78 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.136 (2026-07-02)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core,local:** projection/publisher/bus correctness (plan A5,A7) ([86e27a5](https://github.com/ReventlessDev/reventless-core/commit/86e27a5d68873d7dd9e746f9b4126f4d5bad63c9))
11
+
12
+
13
+ # 3.0.0-alpha.135 (2026-06-29)
14
+
15
+ ### Features
16
+
17
+ * external-system boxes for translation slices (Event Graph data) ([3f8ad39](https://github.com/ReventlessDev/reventless-core/commit/3f8ad39b78a3cb1182d59a0e1fb203b7dcb7379b))
18
+
19
+
6
20
  # 3.0.0-alpha.134 (2026-06-27)
7
21
 
8
22
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.134",
3
+ "version": "3.0.0-alpha.136",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,22 +28,22 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.23",
31
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
32
32
  "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
33
- "@reventlessdev/rescript-jest": "1.0.0-alpha.3",
33
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
34
34
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
35
35
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
37
37
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.81",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.21",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.61",
41
- "@reventlessdev/rescript-uuid": "1.1.0-alpha.13"
38
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.83",
39
+ "@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
40
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.22",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.63"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "^12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.47"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.48"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "^12.3.0"
@@ -103,8 +103,16 @@ let applyChanges = async (
103
103
  `${action}(${id}): beforeStates:${beforeCount->Int.toString} afterStates:${afterCount->Int.toString} added:${addedCount->Int.toString} changed:${changedCount->Int.toString} deleted:${deletedCount->Int.toString}`,
104
104
  )
105
105
  let result = await [deleteBatch(batchToDelete), saveBatch(batchToSave)]->Promise.all
106
- switch result {
107
- | _ => Ok() // TODO: Error handling
106
+ // Propagate the first storage failure instead of swallowing it — a failed
107
+ // delete/save was previously reported as a successful projection.
108
+ switch result->Array.find(r =>
109
+ switch r {
110
+ | Error(_) => true
111
+ | Ok(_) => false
112
+ }
113
+ ) {
114
+ | Some(err) => err
115
+ | None => Ok()
108
116
  }
109
117
  }
110
118
 
@@ -236,10 +244,12 @@ let handleAction = async (
236
244
  logAction(`DeleteMany(${ids->Array.joinUnsafe(", ")})`)
237
245
  await deleteBatch(ids->Array.map(id => (id, None)))
238
246
 
239
- // TODO: add missing actions
247
+ // An action reaching here is one this projection engine doesn't implement.
248
+ // Returning Ok() reported the read-model write as done while silently losing
249
+ // it; surface a storage error instead.
240
250
  | _ =>
241
- logAction("Error: Action not yet supported !")
242
- Ok()
251
+ logAction("Error: projection action not supported")
252
+ Error(ReventlessInfra.QueryDb.NotSavedToStorage("unsupported projection action"))
243
253
  }
244
254
  }
245
255
 
@@ -285,11 +295,10 @@ let optimizeActions = actions => {
285
295
  [action]
286
296
  } else {
287
297
  let lastAction = optimizedActions->Array.getUnsafe(optimizedActionsCount - 1)
288
- let previousActions = if optimizedActionsCount == 1 {
289
- []
290
- } else {
291
- optimizedActions->Array.slice(~start=0, ~end=optimizedActionsCount)
292
- }
298
+ // Everything BEFORE the last action. `~end` is exclusive, so this must be
299
+ // `count - 1`; using `count` kept `lastAction` in the slice while the merged
300
+ // action was also appended, applying the last action twice.
301
+ let previousActions = optimizedActions->Array.slice(~start=0, ~end=optimizedActionsCount - 1)
293
302
 
294
303
  switch (lastAction, action) {
295
304
  // SINGLE STATES
@@ -174,14 +174,19 @@ async function applyChanges(action, id, beforeStates, afterStates, param, param$
174
174
  ]);
175
175
  let deletedCount = batchToDelete.length;
176
176
  logAction(action + `(` + id + `): beforeStates:` + beforeCount.toString() + ` afterStates:` + afterCount.toString() + ` added:` + addedCount.toString() + ` changed:` + changedCount.toString() + ` deleted:` + deletedCount.toString());
177
- await Promise.all([
177
+ let result = await Promise.all([
178
178
  param.deleteBatch(batchToDelete),
179
179
  param.saveBatch(batchToSave)
180
180
  ]);
181
- return {
182
- TAG: "Ok",
183
- _0: undefined
184
- };
181
+ let err = result.find(r => r.TAG !== "Ok");
182
+ if (err !== undefined) {
183
+ return err;
184
+ } else {
185
+ return {
186
+ TAG: "Ok",
187
+ _0: undefined
188
+ };
189
+ }
185
190
  }
186
191
 
187
192
  function stateToString(state) {
@@ -369,10 +374,13 @@ async function handleAction(action, operations, subIdConfig) {
369
374
  _0: "MissingSubIdConfig"
370
375
  };
371
376
  default:
372
- logAction("Error: Action not yet supported !");
377
+ logAction("Error: projection action not supported");
373
378
  return {
374
- TAG: "Ok",
375
- _0: undefined
379
+ TAG: "Error",
380
+ _0: {
381
+ TAG: "NotSavedToStorage",
382
+ _0: "unsupported projection action"
383
+ }
376
384
  };
377
385
  }
378
386
  }
@@ -456,7 +464,7 @@ function optimizeActions(actions) {
456
464
  return [action];
457
465
  }
458
466
  let lastAction = optimizedActions[optimizedActionsCount - 1 | 0];
459
- let previousActions = optimizedActionsCount === 1 ? [] : optimizedActions.slice(0, optimizedActionsCount);
467
+ let previousActions = optimizedActions.slice(0, optimizedActionsCount - 1 | 0);
460
468
  if (typeof lastAction === "object") {
461
469
  switch (lastAction.TAG) {
462
470
  case "Create" :
@@ -185,11 +185,14 @@ module Make = (
185
185
  ~appendedEventCount=generatedEvents'->Array.length,
186
186
  )
187
187
  EffectLogger.logInfo(~comp, `append: id=${idStr}`)->Effect.map(_ => Ok(perRef))
188
- | Error(msg) if msg->String.includes("conflict") =>
188
+ | Error(EventLog.Conflict) =>
189
+ // Signal the outer replay+re-decide retry loop (which matches
190
+ // Error(_)); the string is an internal marker, no longer a
191
+ // cross-component substring sentinel.
189
192
  EffectLogger.logWarn(~comp, `conflict: id=${idStr}, will retry`)->Effect.map(
190
- _ => Error(msg),
193
+ _ => Error("conflict"),
191
194
  )
192
- | Error(msg) =>
195
+ | Error(EventLog.StorageFailure(msg)) =>
193
196
  let perRef = reportFinalOutcomes(
194
197
  outcomes,
195
198
  ~entityId=idStr,
@@ -146,14 +146,15 @@ function Make(Spec) {
146
146
  }));
147
147
  }
148
148
  let msg = appendResult._0;
149
- if (msg.includes("conflict")) {
149
+ if (typeof msg !== "object") {
150
150
  return Effect.map(EffectLogger$ReventlessCore.logWarn(comp, undefined, `conflict: id=` + idStr + `, will retry`), () => ({
151
151
  TAG: "Error",
152
- _0: msg
152
+ _0: "conflict"
153
153
  }));
154
154
  }
155
- let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg);
156
- return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg), () => ({
155
+ let msg$1 = msg._0;
156
+ let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg$1);
157
+ return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg$1), () => ({
157
158
  TAG: "Ok",
158
159
  _0: perRef$1
159
160
  }));
@@ -7,7 +7,19 @@ type component<'operations> = Component.t<t, outputs, 'operations>
7
7
 
8
8
  exception ReplayError(string)
9
9
 
10
- type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, string>>
10
+ // Typed append outcome. `Conflict` is the optimistic-concurrency sentinel — the
11
+ // deliberate append-condition failure (seq_nr/position already taken) that the
12
+ // caller retries by re-reading and re-deciding. Every other failure is a
13
+ // `StorageFailure` carrying its message. This replaces the former string sentinel
14
+ // that was detected by substring-matching a pretty-printed Cause across three
15
+ // layers — a real error whose text happened to contain "conflict" would have
16
+ // been retried as an OCC conflict, and a misclassified conflict would surface as
17
+ // a permanent error.
18
+ type appendError =
19
+ | Conflict
20
+ | StorageFailure(string)
21
+
22
+ type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, appendError>>
11
23
  type replay<'id, 'event> = 'id => promise<array<'event>>
12
24
  type replayStream<'id, 'event> = 'id => Stream.t<'event, string, unit>
13
25
  type appendStream<'id, 'event> = (int, 'id, Stream.t<'event, string, unit>) => Effect.t<unit, string, unit>
@@ -16,14 +16,21 @@ module type T = {
16
16
  // Retry schedule for transient storage errors.
17
17
  // Exponential backoff: 100ms, ~200ms, ~400ms, ~800ms, ~1600ms — max 5 retries.
18
18
  // Only retries on recognised transient error messages; permanent errors propagate immediately.
19
- let isTransient = (msg: string) =>
20
- msg->String.includes("ThrottlingException") ||
21
- msg->String.includes("ProvisionedThroughputExceededException") ||
22
- msg->String.includes("ServiceUnavailable") ||
23
- msg->String.includes("RequestLimitExceeded") ||
24
- msg->String.includes("InternalServerError")
25
-
26
- let storageRetrySchedule: Schedule.t<(Duration.t, int), string, unit> =
19
+ // Retry only recognised transient storage failures — never a Conflict (a retry
20
+ // of the same append would just conflict again; the OCC retry happens a layer up
21
+ // via replay+re-decide).
22
+ let isTransient = (e: EventLog.appendError) =>
23
+ switch e {
24
+ | EventLog.Conflict => false
25
+ | StorageFailure(msg) =>
26
+ msg->String.includes("ThrottlingException") ||
27
+ msg->String.includes("ProvisionedThroughputExceededException") ||
28
+ msg->String.includes("ServiceUnavailable") ||
29
+ msg->String.includes("RequestLimitExceeded") ||
30
+ msg->String.includes("InternalServerError")
31
+ }
32
+
33
+ let storageRetrySchedule: Schedule.t<(Duration.t, int), EventLog.appendError, unit> =
27
34
  Schedule.exponential(Duration.millis(100))
28
35
  ->Schedule.jittered
29
36
  ->Schedule.intersect(Schedule.recurs(5))
@@ -112,44 +119,40 @@ module Make = (Spec: ReventlessInfra.EventLog.T, Ops: Ops with module Spec = Spe
112
119
  }
113
120
  }
114
121
 
115
- // append returns result<unit, string> — never throws.
116
- // Storage errors are retried with exponential backoff (up to 5 times for transient errors).
117
- // After exhausting retries, or on permanent errors, returns Error.
118
- let isConflict = (msg: string) => msg->String.includes("conflict")
119
-
122
+ // append returns result<unit, EventLog.appendError> — never throws.
123
+ // Transient storage failures are retried with exponential backoff (up to 5
124
+ // times); a Conflict is returned immediately (retried a layer up), and other
125
+ // failures propagate after retries are exhausted.
120
126
  let append = async (sequenceNr, id, events') => {
121
127
  let eventsJson = events'->encodeEvents'(id, sequenceNr)
122
128
  let idStr = id->Spec.Id.toString
123
- // Build an Effect that fails with a string on storage error (enabling retry)
129
+ // A thrown storage error becomes a StorageFailure; a returned Error keeps its
130
+ // typed variant. The Effect fails with the typed appendError so the schedule
131
+ // can retry only transient failures, then we collapse the outcome back into a
132
+ // result (no Cause stringification / substring matching).
124
133
  let storageEffect =
125
134
  Effect.tryPromise(
126
- ~catch=(err: unknown) => Util.Error.messageFromUnknown(err, "storage error"),
135
+ ~catch=(err: unknown) =>
136
+ EventLog.StorageFailure(Util.Error.messageFromUnknown(err, "storage error")),
127
137
  () => Ops.storage.append(sequenceNr, idStr, eventsJson),
128
138
  )
129
139
  ->Effect.flatMap(result =>
130
140
  switch result {
131
- | Ok(_) => Effect.succeed(())
132
- | Error(msg) => Effect.fail(msg)
141
+ | Ok(_) => Effect.succeed()
142
+ | Error(e) => Effect.fail(e)
133
143
  }
134
144
  )
135
145
  ->Effect.retry(storageRetrySchedule)
136
- let exit = await storageEffect->Effect.runPromiseExit
137
- if exit->Exit.isSuccess {
138
- await publishToEventTopic(id, events', eventsJson)
139
- } else {
140
- // Retry exhausted — extract the final error message from the Cause.
141
- // Use Cause.pretty so defects (uncaught throws) are surfaced too, not
142
- // just typed failures from Cause.failures.
143
- let failMsg = exit->Exit.match(
144
- ~onFailure=cause => cause->Cause.pretty,
145
- ~onSuccess=_ => "storage error", // unreachable: we are in the isFailure branch
146
- )
147
- // Propagate conflict errors without wrapping so callers can detect them
148
- if isConflict(failMsg) {
149
- Error("conflict")
150
- } else {
151
- Error(`EventLog: Error: Couldn't append for ${Spec.name}(${idStr}): ${failMsg}`)
152
- }
146
+ ->Effect.map(_ => Ok())
147
+ ->Effect.catchAll(e => Effect.succeed(Error(e)))
148
+ switch await storageEffect->Effect.runPromise {
149
+ | Ok() =>
150
+ (await publishToEventTopic(id, events', eventsJson))->Result.mapError(msg => EventLog.StorageFailure(
151
+ msg,
152
+ ))
153
+ | Error(EventLog.Conflict) => Error(EventLog.Conflict)
154
+ | Error(StorageFailure(msg)) =>
155
+ Error(EventLog.StorageFailure(`EventLog: Error: Couldn't append for ${Spec.name}(${idStr}): ${msg}`))
153
156
  }
154
157
  }
155
158
 
@@ -1,12 +1,10 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as Exit from "@reventlessdev/rescript-effect/src/Exit.res.mjs";
4
3
  import * as Effect from "@reventlessdev/rescript-effect/src/Effect.res.mjs";
5
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
- import * as Exit$1 from "effect/Exit";
7
5
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
8
- import * as Cause from "effect/Cause";
9
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
10
8
  import * as Effect$1 from "effect/Effect";
11
9
  import * as Stream from "effect/Stream";
12
10
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
@@ -18,7 +16,11 @@ import * as Util_Error$ReventlessCore from "../../util/Util_Error.res.mjs";
18
16
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
19
17
  import * as EventPublish_Callback$ReventlessCore from "./EventPublish_Callback.res.mjs";
20
18
 
21
- function isTransient(msg) {
19
+ function isTransient(e) {
20
+ if (typeof e !== "object") {
21
+ return false;
22
+ }
23
+ let msg = e._0;
22
24
  if (msg.includes("ThrottlingException") || msg.includes("ProvisionedThroughputExceededException") || msg.includes("ServiceUnavailable") || msg.includes("RequestLimitExceeded")) {
23
25
  return true;
24
26
  } else {
@@ -111,27 +113,42 @@ function Make(Spec) {
111
113
  let append = async (sequenceNr, id, events$p) => {
112
114
  let eventsJson = encodeEvents$p(events$p, id, sequenceNr);
113
115
  let idStr = Spec.Id.toString(id);
114
- let storageEffect = Effect$1.retry(Effect$1.flatMap(Effect.tryPromise(err => Util_Error$ReventlessCore.messageFromUnknown(err, "storage error"), () => Ops.storage.append(sequenceNr, idStr, eventsJson)), result => {
116
+ let storageEffect = Effect$1.catchAll(Effect$1.map(Effect$1.retry(Effect$1.flatMap(Effect.tryPromise(err => ({
117
+ TAG: "StorageFailure",
118
+ _0: Util_Error$ReventlessCore.messageFromUnknown(err, "storage error")
119
+ }), () => Ops.storage.append(sequenceNr, idStr, eventsJson)), result => {
115
120
  if (result.TAG === "Ok") {
116
121
  return Effect$1.succeed();
117
122
  } else {
118
123
  return Effect$1.fail(result._0);
119
124
  }
120
- }), storageRetrySchedule);
121
- let exit = await Effect$1.runPromiseExit(storageEffect);
122
- if (Exit$1.isSuccess(exit)) {
123
- return await publishToEventTopic(id, events$p, eventsJson);
125
+ }), storageRetrySchedule), () => ({
126
+ TAG: "Ok",
127
+ _0: undefined
128
+ })), e => Effect$1.succeed({
129
+ TAG: "Error",
130
+ _0: e
131
+ }));
132
+ let match = await Effect$1.runPromise(storageEffect);
133
+ if (match.TAG === "Ok") {
134
+ return Stdlib_Result.mapError(await publishToEventTopic(id, events$p, eventsJson), msg => ({
135
+ TAG: "StorageFailure",
136
+ _0: msg
137
+ }));
124
138
  }
125
- let failMsg = Exit.match(exit, cause => Cause.pretty(cause), () => "storage error");
126
- if (failMsg.includes("conflict")) {
139
+ let msg = match._0;
140
+ if (typeof msg !== "object") {
127
141
  return {
128
142
  TAG: "Error",
129
- _0: "conflict"
143
+ _0: "Conflict"
130
144
  };
131
145
  } else {
132
146
  return {
133
147
  TAG: "Error",
134
- _0: `EventLog: Error: Couldn't append for ` + Spec.name + `(` + idStr + `): ` + failMsg
148
+ _0: {
149
+ TAG: "StorageFailure",
150
+ _0: `EventLog: Error: Couldn't append for ` + Spec.name + `(` + idStr + `): ` + msg._0
151
+ }
135
152
  };
136
153
  }
137
154
  };
@@ -19,6 +19,11 @@ module type T = {
19
19
  ~opts: Pulumi.ComponentResource.options=?,
20
20
  ) => component
21
21
  let sourceNames: array<string>
22
+ /** Bare event-variant names consumed across every projection mapping's source event
23
+ schema — names the actual events (so a DCB-log-sourced mapping is captured), unlike
24
+ `sourceNames` which only lists source aggregate / DCB-log names. Surfaced by
25
+ Plugin_Structure as `queryableDef.consumedEventTypes`. */
26
+ let consumedEventNames: array<string>
22
27
  let outputs: component => outputs
23
28
  let operations: component => Pulumi.Output.t<operations>
24
29
  let finish: unit => unit
@@ -25,6 +25,18 @@ module Make = (
25
25
  ->Belt.Set.String.fromArray
26
26
  ->Belt.Set.String.toArray
27
27
 
28
+ // Bare event-variant names consumed across every mapping's source event schema — the
29
+ // union the reflected graph turns into Event→ReadModel "projects" edges (see
30
+ // Plugin_Structure / DomainGraph). A DCB-log-sourced mapping declares a narrow event type
31
+ // (e.g. just `OrderPlaced`), so this captures cross-source events `sourceNames` can't name.
32
+ let consumedEventNames =
33
+ Mappings.mappings
34
+ ->Array.flatMap((module(Mapping: Mappings.Mapping)) =>
35
+ Reventless.DcbTag.extractVariantNames(Mapping.sourceEventSchema->S.castToUnknown)
36
+ )
37
+ ->Belt.Set.String.fromArray
38
+ ->Belt.Set.String.toArray
39
+
28
40
  type api = QueryDbStorage.api
29
41
  type role = QueryDbStorage.role
30
42
  type component = ReadModel.component
@@ -4,6 +4,7 @@ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output
4
4
  import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
5
5
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
6
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
7
+ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
7
8
  import * as Component$ReventlessCore from "../Component.res.mjs";
8
9
  import * as ReadModel$ReventlessCore from "./ReadModel.res.mjs";
9
10
  import * as EventTopic$ReventlessCore from "../EventTopic/EventTopic.res.mjs";
@@ -15,6 +16,7 @@ import * as EventCollector_Builder$ReventlessCore from "../EventCollector/EventC
15
16
  function Make(Spec) {
16
17
  return Mappings => (RuntimeEnvironment => (QueryDbStorage => (QueryDbResolvers => (EventCollectorChannel => (EventCollectorRuntimeBuilder => {
17
18
  let sourceNames = Belt_SetString.toArray(Belt_SetString.fromArray(Mappings.mappings.map(Mapping => Mapping.sourceName)));
19
+ let consumedEventNames = Belt_SetString.toArray(Belt_SetString.fromArray(Mappings.mappings.flatMap(Mapping => DcbTag$Reventless.extractVariantNames(Mapping.sourceEventSchema))));
18
20
  let make = (api, apiRole, allEventTopics, opts) => Component$ReventlessCore.make(ComponentType$ReventlessCore.toString(ReadModel$ReventlessCore.componentType), Spec.name, (extra, extra$1) => {
19
21
  let opts_parent = Component$ReventlessCore.toPulumiResource(extra);
20
22
  let opts = {
@@ -88,6 +90,7 @@ function Make(Spec) {
88
90
  EventCollectorRuntimeBuilder: EventCollectorRuntimeBuilder,
89
91
  make: make,
90
92
  sourceNames: sourceNames,
93
+ consumedEventNames: consumedEventNames,
91
94
  outputs: Component$ReventlessCore.outputs,
92
95
  operations: Component$ReventlessCore.operations,
93
96
  finish: finish
@@ -390,12 +390,17 @@ let make = (
390
390
  ~entityName=R.Spec.name,
391
391
  stateSchema,
392
392
  )
393
+ // The events this read model projects from, qualified to the plugin's event ids so
394
+ // they match the producers' nodes in the graph. Was empty — which dropped projection
395
+ // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
396
+ // aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
397
+ let consumed = qualify(~prefix=name, R.consumedEventNames)
393
398
  ({
394
399
  Reventless.Plugin.name: R.Spec.name,
395
400
  queryField: qf.listFieldName,
396
401
  schema: stateSchema->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
397
- consumedEventTypes: [],
398
- linkedWriteSide: [],
402
+ consumedEventTypes: consumed,
403
+ linkedWriteSide: linkedWriteSideFor(consumed),
399
404
  labelField,
400
405
  searchableFields,
401
406
  statusField: statusFieldFromStateSchema(stateSchema),
@@ -497,6 +502,7 @@ let make = (
497
502
  consumedEventTypes: qualify(~prefix=name, eventVariantNames(OTS.Spec.consumedEventSchema)),
498
503
  inboundCommandTypes: qualify(~prefix=name, commandVariantNames(OTS.Spec.inboundCommandSchema)),
499
504
  targetName: OTS.Spec.targetName,
505
+ externalSystem: OTS.Spec.externalSystem,
500
506
  }: Reventless.Plugin.outboundTranslationSliceDef))
501
507
 
502
508
  // ── Inbound translation slices ────────────────────────────────────────────
@@ -506,6 +512,7 @@ let make = (
506
512
  Reventless.Plugin.name: ITS.Spec.name,
507
513
  commandTypes: qualify(~prefix=name, commandVariantNames(ITS.Spec.commandSchema)),
508
514
  targetName: ITS.Spec.targetName,
515
+ externalSystem: ITS.Spec.externalSystem,
509
516
  }: Reventless.Plugin.inboundTranslationSliceDef))
510
517
 
511
518
  // ── Extensions ───────────────────────────────────────────────────────────
@@ -323,12 +323,13 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
323
323
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
324
324
  let stateSchema = R.Spec.stateSchema;
325
325
  let match = labelFieldsFromStateSchema(R.Spec.name, stateSchema);
326
+ let consumed = qualify(name, R.consumedEventNames);
326
327
  return {
327
328
  name: R.Spec.name,
328
329
  queryField: qf.listFieldName,
329
330
  schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(stateSchema)),
330
- consumedEventTypes: [],
331
- linkedWriteSide: [],
331
+ consumedEventTypes: consumed,
332
+ linkedWriteSide: linkedWriteSideFor(consumed),
332
333
  labelField: match[0],
333
334
  searchableFields: match[1],
334
335
  statusField: statusFieldFromStateSchema(stateSchema),
@@ -394,12 +395,14 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
394
395
  name: OTS.Spec.name,
395
396
  consumedEventTypes: qualify(name, DcbTag$Reventless.extractVariantNames(OTS.Spec.consumedEventSchema)),
396
397
  inboundCommandTypes: qualify(name, DcbTag$Reventless.extractAllVariantNames(OTS.Spec.inboundCommandSchema)),
397
- targetName: OTS.Spec.targetName
398
+ targetName: OTS.Spec.targetName,
399
+ externalSystem: OTS.Spec.externalSystem
398
400
  }));
399
401
  let inboundTranslationSliceDefs = inboundTranslationSlices.map(ITS => ({
400
402
  name: ITS.Spec.name,
401
403
  commandTypes: qualify(name, DcbTag$Reventless.extractAllVariantNames(ITS.Spec.commandSchema)),
402
- targetName: ITS.Spec.targetName
404
+ targetName: ITS.Spec.targetName,
405
+ externalSystem: ITS.Spec.externalSystem
403
406
  }));
404
407
  let extensionDefs = extensions.map(E => {
405
408
  let delegateNames = E.mappings.map(M => M.delegateName);
@@ -38,7 +38,19 @@ let rec describeSchema = (schema: S.t<unknown>): string =>
38
38
  | _ => "unknown"
39
39
  }
40
40
  "array<" ++ itemDesc ++ ">"
41
- | Object(_) => "object"
41
+ | Object({properties}) =>
42
+ // Recurse into nested records. Rendering every object as the literal
43
+ // "object" made the structural hash blind to nested field changes — a
44
+ // nested schema edit produced an identical SHA256, so drift detection
45
+ // silently missed it. Emit a sorted `{name:type,…}` description instead.
46
+ let inner =
47
+ properties
48
+ ->Dict.toArray
49
+ ->Array.filter(((name, _)) => name != "TAG")
50
+ ->Array.toSorted(((a, _), (b, _)) => String.compare(a, b))
51
+ ->Array.map(((name, s)) => name ++ ":" ++ describeSchema(s))
52
+ ->Array.join(",")
53
+ "{" ++ inner ++ "}"
42
54
  | _ => "unknown"
43
55
  }
44
56
 
@@ -23,7 +23,8 @@ function describeSchema(schema) {
23
23
  itemDesc = additionalItems === "strip" || additionalItems === "strict" ? "unknown" : describeSchema(additionalItems);
24
24
  return "array<" + itemDesc + ">";
25
25
  case "object" :
26
- return "object";
26
+ let inner = Object.entries(schema.properties).filter(param => param[0] !== "TAG").toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0])).map(param => param[0] + ":" + describeSchema(param[1])).join(",");
27
+ return "{" + inner + "}";
27
28
  case "union" :
28
29
  let nonNull = schema.anyOf.filter(v => {
29
30
  switch (v.type) {
@@ -34,9 +35,9 @@ function describeSchema(schema) {
34
35
  return true;
35
36
  }
36
37
  });
37
- let inner = nonNull[0];
38
- if (inner !== undefined) {
39
- return "option<" + describeSchema(inner) + ">";
38
+ let inner$1 = nonNull[0];
39
+ if (inner$1 !== undefined) {
40
+ return "option<" + describeSchema(inner$1) + ">";
40
41
  } else {
41
42
  return "unknown";
42
43
  }
@@ -63,7 +63,12 @@ module Make = (Spec: Spec, Config: Config) => {
63
63
  ~comp="CommandPublisher",
64
64
  `send: bufferSize: ${bufferSizeStr}, chunk: ${chunkCountStr}, size: ${sizeStr}`,
65
65
  )->Effect.runSync
66
- let commandsToSend = buffer->Array.toSpliced(~start=0, ~remove=size, ~insert=[])
66
+ // Take the first `size` commands and remove them from the buffer.
67
+ // (`toSpliced` is non-mutating and returns the *remainder*, so the buffer
68
+ // never drained: send() recursed on the same buffer and published the
69
+ // wrong slice. `slice` captures the chunk; `splice` drains it in place.)
70
+ let commandsToSend = buffer->Array.slice(~start=0, ~end=size)
71
+ buffer->Array.splice(~start=0, ~remove=size, ~insert=[])
67
72
  let promise = Config.publishCommands(Spec.name, commandsToSend->toJsons)
68
73
  running := Some(promise)
69
74
  switch await promise {
@@ -82,7 +87,12 @@ module Make = (Spec: Spec, Config: Config) => {
82
87
  | SendAllInOneChunk =>
83
88
  let size = buffer->Array.length
84
89
  if size > 0 {
85
- let commandsToSend = buffer->Array.toSpliced(~start=0, ~remove=size, ~insert=[])
90
+ // Take the first `size` commands and remove them from the buffer.
91
+ // (`toSpliced` is non-mutating and returns the *remainder*, so the buffer
92
+ // never drained: send() recursed on the same buffer and published the
93
+ // wrong slice. `slice` captures the chunk; `splice` drains it in place.)
94
+ let commandsToSend = buffer->Array.slice(~start=0, ~end=size)
95
+ buffer->Array.splice(~start=0, ~remove=size, ~insert=[])
86
96
  let promise = Config.publishCommands(Spec.name, commandsToSend->toJsons)
87
97
  running := Some(promise)
88
98
  switch await promise {
@@ -115,19 +125,19 @@ module Make = (Spec: Spec, Config: Config) => {
115
125
 
116
126
  let clear = () => {
117
127
  EffectLogger.logDebug(~comp="CommandPublisher", "clear")->Effect.runSync
118
- let _ = buffer->Array.removeInPlace(0)
128
+ // Truncate the whole buffer. `removeInPlace(0)` is `splice(0, 1)` — it drops
129
+ // only the first element, so the rest leaked out on a later publish.
130
+ buffer->Array.splice(~start=0, ~remove=buffer->Array.length, ~insert=[])
119
131
  flush := false
120
132
  }
121
133
 
122
134
  let flush = async () => {
123
135
  EffectLogger.logDebug(~comp="CommandPublisher", "flush")->Effect.runSync
124
136
  flush := true
125
- switch running.contents {
126
- | None => await send()
127
- | _ =>
128
- while running.contents->Option.isNone {
129
- await finishRunning()
130
- }
131
- }
137
+ // `send` already awaits any in-flight publish (finishRunning) before draining
138
+ // the buffer, and flush:=true forces a partial final chunk. The previous
139
+ // in-flight arm looped on `running.contents->Option.isNone` — dead, since this
140
+ // path only ran when running was Some — so buffered commands were never sent.
141
+ await send()
132
142
  }
133
143
  }