@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.
- package/CHANGELOG.md +14 -0
- package/package.json +8 -8
- package/src/Projection.res +19 -10
- package/src/Projection.res.mjs +17 -9
- package/src/components/Aggregate/Aggregate_Callback.res +6 -3
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +5 -4
- package/src/components/EventLog/EventLog.res +13 -1
- package/src/components/EventLog/EventLog_Operations.res +37 -34
- package/src/components/EventLog/EventLog_Operations.res.mjs +30 -13
- package/src/components/ReadModel/ReadModel.res +5 -0
- package/src/components/ReadModel/ReadModel_Builder.res +12 -0
- package/src/components/ReadModel/ReadModel_Builder.res.mjs +3 -0
- package/src/plugin/component/Plugin_Structure.res +9 -2
- package/src/plugin/component/Plugin_Structure.res.mjs +7 -4
- package/src/plugin/component/SchemaWalker.res +13 -1
- package/src/plugin/component/SchemaWalker.res.mjs +5 -4
- package/src/util/CommandPublisher.res +20 -10
- package/src/util/CommandPublisher.res.mjs +6 -10
- package/src/util/Util_Array.res +1 -1
- package/src/util/Util_Array.res.mjs +1 -1
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +2 -1
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +4 -2
- package/tests/aggregate/AggregateConflictTest.res +3 -3
- package/tests/aggregate/AggregateConflictTest.res.mjs +1 -1
- package/tests/aggregate/AggregateFixtures.res +3 -3
- package/tests/aggregate/AggregateFixtures.res.mjs +4 -1
- package/tests/eventlog/EventLogFixtures.res +2 -2
- package/tests/eventlog/EventLogFixtures.res.mjs +8 -2
- package/tests/eventlog/EventLogRetryTest.res +12 -11
- package/tests/eventlog/EventLogRetryTest.res.mjs +31 -7
- package/tests/plugin/ManifestVisibilityTest.res +14 -0
- package/tests/plugin/ManifestVisibilityTest.res.mjs +10 -0
- package/tests/projection/ProjectionOptimizeTest.res +38 -0
- package/tests/projection/ProjectionOptimizeTest.res.mjs +75 -0
- package/tests/util/CommandPublisherTest.res +56 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
39
|
-
"@reventlessdev/
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/
|
|
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.
|
|
46
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.48"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"rescript": "^12.3.0"
|
package/src/Projection.res
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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
|
-
//
|
|
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:
|
|
242
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
package/src/Projection.res.mjs
CHANGED
|
@@ -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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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:
|
|
377
|
+
logAction("Error: projection action not supported");
|
|
373
378
|
return {
|
|
374
|
-
TAG: "
|
|
375
|
-
_0:
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
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:
|
|
152
|
+
_0: "conflict"
|
|
153
153
|
}));
|
|
154
154
|
}
|
|
155
|
-
let
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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,
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
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
|
-
//
|
|
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) =>
|
|
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(
|
|
141
|
+
| Ok(_) => Effect.succeed()
|
|
142
|
+
| Error(e) => Effect.fail(e)
|
|
133
143
|
}
|
|
134
144
|
)
|
|
135
145
|
->Effect.retry(storageRetrySchedule)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
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 =>
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
126
|
-
if (
|
|
139
|
+
let msg = match._0;
|
|
140
|
+
if (typeof msg !== "object") {
|
|
127
141
|
return {
|
|
128
142
|
TAG: "Error",
|
|
129
|
-
_0: "
|
|
143
|
+
_0: "Conflict"
|
|
130
144
|
};
|
|
131
145
|
} else {
|
|
132
146
|
return {
|
|
133
147
|
TAG: "Error",
|
|
134
|
-
_0:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
}
|