@reventlessdev/reventless-core 3.0.0-alpha.135 → 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 +7 -0
- package/package.json +7 -7
- 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/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/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/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,13 @@
|
|
|
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
|
+
|
|
6
13
|
# 3.0.0-alpha.135 (2026-06-29)
|
|
7
14
|
|
|
8
15
|
### Features
|
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,17 +28,17 @@
|
|
|
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.
|
|
32
|
-
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
|
|
31
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.24",
|
|
33
32
|
"@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
|
|
34
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.
|
|
33
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.5",
|
|
35
34
|
"@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
|
|
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.83",
|
|
38
39
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
|
|
39
|
-
"@reventlessdev/reventless-interop": "3.0.0-alpha.
|
|
40
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
41
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.82"
|
|
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",
|
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
|
};
|
|
@@ -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
|
}
|
|
@@ -54,7 +54,8 @@ function Make(Spec) {
|
|
|
54
54
|
if (size <= 0) {
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
|
-
let commandsToSend = buffer.
|
|
57
|
+
let commandsToSend = buffer.slice(0, size);
|
|
58
|
+
buffer.splice(0, size);
|
|
58
59
|
let promise = Config.publishCommands(Spec.name, toJsons(commandsToSend));
|
|
59
60
|
running.contents = promise;
|
|
60
61
|
let exit = 0;
|
|
@@ -88,7 +89,8 @@ function Make(Spec) {
|
|
|
88
89
|
let bufferSizeStr = buffer.length.toString();
|
|
89
90
|
let chunkCountStr = chunkCount.contents.toString();
|
|
90
91
|
Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, `send: bufferSize: ` + bufferSizeStr + `, chunk: ` + chunkCountStr + `, size: ` + sizeStr));
|
|
91
|
-
let commandsToSend$1 = buffer.
|
|
92
|
+
let commandsToSend$1 = buffer.slice(0, size$1);
|
|
93
|
+
buffer.splice(0, size$1);
|
|
92
94
|
let promise$1 = Config.publishCommands(Spec.name, toJsons(commandsToSend$1));
|
|
93
95
|
running.contents = promise$1;
|
|
94
96
|
let exit$1 = 0;
|
|
@@ -127,19 +129,13 @@ function Make(Spec) {
|
|
|
127
129
|
};
|
|
128
130
|
let clear = () => {
|
|
129
131
|
Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, "clear"));
|
|
130
|
-
buffer.splice(0,
|
|
132
|
+
buffer.splice(0, buffer.length);
|
|
131
133
|
flush.contents = false;
|
|
132
134
|
};
|
|
133
135
|
let flush$1 = async () => {
|
|
134
136
|
Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, "flush"));
|
|
135
137
|
flush.contents = true;
|
|
136
|
-
|
|
137
|
-
if (match === undefined) {
|
|
138
|
-
return await send();
|
|
139
|
-
}
|
|
140
|
-
while (Stdlib_Option.isNone(running.contents)) {
|
|
141
|
-
await finishRunning();
|
|
142
|
-
};
|
|
138
|
+
return await send();
|
|
143
139
|
};
|
|
144
140
|
return {
|
|
145
141
|
buffer: buffer,
|
package/src/util/Util_Array.res
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
let containsByPredicate: (array<'a>, 'a => bool) => bool = (arr, predicate) =>
|
|
2
|
-
arr->Array.find(predicate)->Option.
|
|
2
|
+
arr->Array.find(predicate)->Option.isSome
|
|
@@ -41,7 +41,7 @@ module TestBehavior = {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
type mockEL = {
|
|
44
|
-
appendFn: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit,
|
|
44
|
+
appendFn: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, EventLog.appendError>>,
|
|
45
45
|
replayFn: string => promise<array<AggSpec.event>>,
|
|
46
46
|
replayStreamFn: string => Stream.t<AggSpec.event, string, unit>,
|
|
47
47
|
getAll: unit => array<Message.event'<string, AggSpec.event>>,
|
|
@@ -61,7 +61,7 @@ let makeMockEL = (): mockEL => {
|
|
|
61
61
|
appendCount := appendCount.contents + 1
|
|
62
62
|
if failConflictRef.contents > 0 {
|
|
63
63
|
failConflictRef := failConflictRef.contents - 1
|
|
64
|
-
Error(
|
|
64
|
+
Error(EventLog.Conflict)
|
|
65
65
|
} else {
|
|
66
66
|
storedRef := storedRef.contents->Array.concat(newEvents)
|
|
67
67
|
Ok(())
|
|
@@ -114,7 +114,7 @@ module TestOps = {
|
|
|
114
114
|
type event = AggSpec.event
|
|
115
115
|
}
|
|
116
116
|
type operations = {
|
|
117
|
-
append: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit,
|
|
117
|
+
append: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, EventLog.appendError>>,
|
|
118
118
|
replay: string => promise<array<AggSpec.event>>,
|
|
119
119
|
replayStream: string => Stream.t<AggSpec.event, string, unit>,
|
|
120
120
|
appendStream: (int, string, Stream.t<AggSpec.event, string, unit>) => Effect.t<unit, string, unit>,
|
|
@@ -60,7 +60,7 @@ module TestBehavior = {
|
|
|
60
60
|
// ─────────────────────────────────────────────────────────────
|
|
61
61
|
|
|
62
62
|
type mockEL = {
|
|
63
|
-
appendFn: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit,
|
|
63
|
+
appendFn: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, EventLog.appendError>>,
|
|
64
64
|
replayFn: string => promise<array<AggSpec.event>>,
|
|
65
65
|
replayStreamFn: string => Stream.t<AggSpec.event, string, unit>,
|
|
66
66
|
getAll: unit => array<Message.event'<string, AggSpec.event>>,
|
|
@@ -75,7 +75,7 @@ let makeMockEL = (): mockEL => {
|
|
|
75
75
|
let appendFn = async (_seqNr, _id, newEvents) =>
|
|
76
76
|
if failRef.contents {
|
|
77
77
|
failRef := false
|
|
78
|
-
Error("append failed")
|
|
78
|
+
Error(EventLog.StorageFailure("append failed"))
|
|
79
79
|
} else {
|
|
80
80
|
storedRef := storedRef.contents->Array.concat(newEvents)
|
|
81
81
|
Ok(())
|
|
@@ -129,7 +129,7 @@ module TestOps = {
|
|
|
129
129
|
int,
|
|
130
130
|
string,
|
|
131
131
|
array<Message.event'<string, AggSpec.event>>,
|
|
132
|
-
) => promise<result<unit,
|
|
132
|
+
) => promise<result<unit, EventLog.appendError>>,
|
|
133
133
|
replay: string => promise<array<AggSpec.event>>,
|
|
134
134
|
replayStream: string => Stream.t<AggSpec.event, string, unit>,
|
|
135
135
|
appendStream: (int, string, Stream.t<AggSpec.event, string, unit>) => Effect.t<unit, string, unit>,
|
|
@@ -129,7 +129,10 @@ function makeMockEL() {
|
|
|
129
129
|
failRef.contents = false;
|
|
130
130
|
return {
|
|
131
131
|
TAG: "Error",
|
|
132
|
-
_0:
|
|
132
|
+
_0: {
|
|
133
|
+
TAG: "StorageFailure",
|
|
134
|
+
_0: "append failed"
|
|
135
|
+
}
|
|
133
136
|
};
|
|
134
137
|
} else {
|
|
135
138
|
storedRef.contents = storedRef.contents.concat(newEvents);
|
|
@@ -28,10 +28,10 @@ let mockStorage: EventLog_Adapter.operations = {
|
|
|
28
28
|
appendCallCount := appendCallCount.contents + 1
|
|
29
29
|
if failNextAppend.contents {
|
|
30
30
|
failNextAppend := false
|
|
31
|
-
Error("mock storage failure")
|
|
31
|
+
Error(EventLog.StorageFailure("mock storage failure"))
|
|
32
32
|
} else if failNextAppendsWithTransient.contents > 0 {
|
|
33
33
|
failNextAppendsWithTransient := failNextAppendsWithTransient.contents - 1
|
|
34
|
-
Error("ThrottlingException: Rate exceeded")
|
|
34
|
+
Error(EventLog.StorageFailure("ThrottlingException: Rate exceeded"))
|
|
35
35
|
} else {
|
|
36
36
|
let existing = storedEvents.contents->Dict.get(id)->Option.getOr([])
|
|
37
37
|
storedEvents.contents->Dict.set(id, existing->Array.concat(jsons))
|
|
@@ -47,14 +47,20 @@ async function mockStorage_append(_seqNr, id, jsons) {
|
|
|
47
47
|
failNextAppend.contents = false;
|
|
48
48
|
return {
|
|
49
49
|
TAG: "Error",
|
|
50
|
-
_0:
|
|
50
|
+
_0: {
|
|
51
|
+
TAG: "StorageFailure",
|
|
52
|
+
_0: "mock storage failure"
|
|
53
|
+
}
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
56
|
if (failNextAppendsWithTransient.contents > 0) {
|
|
54
57
|
failNextAppendsWithTransient.contents = failNextAppendsWithTransient.contents - 1 | 0;
|
|
55
58
|
return {
|
|
56
59
|
TAG: "Error",
|
|
57
|
-
_0:
|
|
60
|
+
_0: {
|
|
61
|
+
TAG: "StorageFailure",
|
|
62
|
+
_0: "ThrottlingException: Rate exceeded"
|
|
63
|
+
}
|
|
58
64
|
};
|
|
59
65
|
}
|
|
60
66
|
let existing = Stdlib_Option.getOr(storedEvents.contents[id], []);
|
|
@@ -16,36 +16,37 @@ let _ = beforeEach(() => reset())
|
|
|
16
16
|
|
|
17
17
|
describe("EventLog_Operations — retry:", () => {
|
|
18
18
|
describe("isTransient predicate:", () => {
|
|
19
|
+
let transient = msg => EventLog_Operations.isTransient(EventLog.StorageFailure(msg))
|
|
19
20
|
testPromise("ThrottlingException is transient", async () => {
|
|
20
|
-
expect(
|
|
21
|
+
expect(transient("ThrottlingException: Rate exceeded"))->toBe(true)
|
|
21
22
|
})
|
|
22
23
|
|
|
23
24
|
testPromise("ProvisionedThroughputExceededException is transient", async () => {
|
|
24
|
-
expect(
|
|
25
|
-
EventLog_Operations.isTransient("ProvisionedThroughputExceededException"),
|
|
26
|
-
)->toBe(true)
|
|
25
|
+
expect(transient("ProvisionedThroughputExceededException"))->toBe(true)
|
|
27
26
|
})
|
|
28
27
|
|
|
29
28
|
testPromise("ServiceUnavailable is transient", async () => {
|
|
30
|
-
expect(
|
|
29
|
+
expect(transient("ServiceUnavailable"))->toBe(true)
|
|
31
30
|
})
|
|
32
31
|
|
|
33
32
|
testPromise("RequestLimitExceeded is transient", async () => {
|
|
34
|
-
expect(
|
|
33
|
+
expect(transient("RequestLimitExceeded"))->toBe(true)
|
|
35
34
|
})
|
|
36
35
|
|
|
37
36
|
testPromise("InternalServerError is transient", async () => {
|
|
38
|
-
expect(
|
|
39
|
-
true,
|
|
40
|
-
)
|
|
37
|
+
expect(transient("InternalServerError: something went wrong"))->toBe(true)
|
|
41
38
|
})
|
|
42
39
|
|
|
43
40
|
testPromise("ValidationException is not transient", async () => {
|
|
44
|
-
expect(
|
|
41
|
+
expect(transient("ValidationException: invalid field"))->toBe(false)
|
|
45
42
|
})
|
|
46
43
|
|
|
47
44
|
testPromise("generic mock storage failure is not transient", async () => {
|
|
48
|
-
expect(
|
|
45
|
+
expect(transient("mock storage failure"))->toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
testPromise("a Conflict is never transient (retried a layer up, not here)", async () => {
|
|
49
|
+
expect(EventLog_Operations.isTransient(EventLog.Conflict))->toBe(false)
|
|
49
50
|
})
|
|
50
51
|
})
|
|
51
52
|
|
|
@@ -74,25 +74,49 @@ globalThis.beforeEach(() => EventLogFixtures$ReventlessCore.reset());
|
|
|
74
74
|
globalThis.describe("EventLog_Operations — retry:", () => {
|
|
75
75
|
globalThis.describe("isTransient predicate:", () => {
|
|
76
76
|
globalThis.test("ThrottlingException is transient", async () => {
|
|
77
|
-
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient(
|
|
77
|
+
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
|
|
78
|
+
TAG: "StorageFailure",
|
|
79
|
+
_0: "ThrottlingException: Rate exceeded"
|
|
80
|
+
})).toBe(true);
|
|
78
81
|
});
|
|
79
82
|
globalThis.test("ProvisionedThroughputExceededException is transient", async () => {
|
|
80
|
-
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient(
|
|
83
|
+
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
|
|
84
|
+
TAG: "StorageFailure",
|
|
85
|
+
_0: "ProvisionedThroughputExceededException"
|
|
86
|
+
})).toBe(true);
|
|
81
87
|
});
|
|
82
88
|
globalThis.test("ServiceUnavailable is transient", async () => {
|
|
83
|
-
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient(
|
|
89
|
+
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
|
|
90
|
+
TAG: "StorageFailure",
|
|
91
|
+
_0: "ServiceUnavailable"
|
|
92
|
+
})).toBe(true);
|
|
84
93
|
});
|
|
85
94
|
globalThis.test("RequestLimitExceeded is transient", async () => {
|
|
86
|
-
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient(
|
|
95
|
+
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
|
|
96
|
+
TAG: "StorageFailure",
|
|
97
|
+
_0: "RequestLimitExceeded"
|
|
98
|
+
})).toBe(true);
|
|
87
99
|
});
|
|
88
100
|
globalThis.test("InternalServerError is transient", async () => {
|
|
89
|
-
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient(
|
|
101
|
+
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
|
|
102
|
+
TAG: "StorageFailure",
|
|
103
|
+
_0: "InternalServerError: something went wrong"
|
|
104
|
+
})).toBe(true);
|
|
90
105
|
});
|
|
91
106
|
globalThis.test("ValidationException is not transient", async () => {
|
|
92
|
-
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient(
|
|
107
|
+
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
|
|
108
|
+
TAG: "StorageFailure",
|
|
109
|
+
_0: "ValidationException: invalid field"
|
|
110
|
+
})).toBe(false);
|
|
93
111
|
});
|
|
94
112
|
globalThis.test("generic mock storage failure is not transient", async () => {
|
|
95
|
-
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient(
|
|
113
|
+
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
|
|
114
|
+
TAG: "StorageFailure",
|
|
115
|
+
_0: "mock storage failure"
|
|
116
|
+
})).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
globalThis.test("a Conflict is never transient (retried a layer up, not here)", async () => {
|
|
119
|
+
globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("Conflict")).toBe(false);
|
|
96
120
|
});
|
|
97
121
|
});
|
|
98
122
|
globalThis.describe("permanent storage failure:", () => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Regression test for Projection.optimizeActions (plan A7 / D2).
|
|
2
|
+
//
|
|
3
|
+
// optimizeActions merges consecutive per-id actions. A non-merging pair (Delete
|
|
4
|
+
// then Update) grows the accumulator to length 2; the next merge (Update+Set)
|
|
5
|
+
// must fold only the last action and keep everything before it exactly once.
|
|
6
|
+
// Before the `~end=count-1` fix, `previousActions` still contained the
|
|
7
|
+
// just-merged action, so the earlier action was duplicated — applied twice.
|
|
8
|
+
|
|
9
|
+
open JestGlobals
|
|
10
|
+
open Reventless.Projection
|
|
11
|
+
|
|
12
|
+
describe("Projection.optimizeActions", () => {
|
|
13
|
+
testSync("does not duplicate the pre-merge action (off-by-one regression)", () => {
|
|
14
|
+
let optimized = Projection.optimizeActions([Delete("a"), Update("a", x => x + 1), Set("a", 99)])
|
|
15
|
+
// Was 3 (with a duplicated Update("a")) before the fix.
|
|
16
|
+
expect(optimized->Array.length)->toBe(2)
|
|
17
|
+
let sets =
|
|
18
|
+
optimized->Array.filterMap(a =>
|
|
19
|
+
switch a {
|
|
20
|
+
| Set(id, s) => Some((id, s))
|
|
21
|
+
| _ => None
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
expect(sets)->toEqual([("a", 99)])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
testSync("merges a same-id Create+Update chain into a single Create", () => {
|
|
28
|
+
let optimized = Projection.optimizeActions([Create("a", 0), Update("a", x => x + 1), Update("a", x => x + 10)])
|
|
29
|
+
let creates =
|
|
30
|
+
optimized->Array.filterMap(a =>
|
|
31
|
+
switch a {
|
|
32
|
+
| Create(id, s) => Some((id, s))
|
|
33
|
+
| _ => None
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
expect(creates)->toEqual([("a", 11)])
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
|
+
import * as Projection$ReventlessCore from "../../src/Projection.res.mjs";
|
|
5
|
+
|
|
6
|
+
globalThis.describe("Projection.optimizeActions", () => {
|
|
7
|
+
globalThis.test("does not duplicate the pre-merge action (off-by-one regression)", () => {
|
|
8
|
+
let optimized = Projection$ReventlessCore.optimizeActions([
|
|
9
|
+
{
|
|
10
|
+
TAG: "Delete",
|
|
11
|
+
_0: "a"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
TAG: "Update",
|
|
15
|
+
_0: "a",
|
|
16
|
+
_1: x => x + 1 | 0
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
TAG: "Set",
|
|
20
|
+
_0: "a",
|
|
21
|
+
_1: 99
|
|
22
|
+
}
|
|
23
|
+
]);
|
|
24
|
+
globalThis.expect(optimized.length).toBe(2);
|
|
25
|
+
let sets = Stdlib_Array.filterMap(optimized, a => {
|
|
26
|
+
if (typeof a !== "object" || a.TAG !== "Set") {
|
|
27
|
+
return;
|
|
28
|
+
} else {
|
|
29
|
+
return [
|
|
30
|
+
a._0,
|
|
31
|
+
a._1
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
globalThis.expect(sets).toEqual([[
|
|
36
|
+
"a",
|
|
37
|
+
99
|
|
38
|
+
]]);
|
|
39
|
+
});
|
|
40
|
+
globalThis.test("merges a same-id Create+Update chain into a single Create", () => {
|
|
41
|
+
let optimized = Projection$ReventlessCore.optimizeActions([
|
|
42
|
+
{
|
|
43
|
+
TAG: "Create",
|
|
44
|
+
_0: "a",
|
|
45
|
+
_1: 0
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
TAG: "Update",
|
|
49
|
+
_0: "a",
|
|
50
|
+
_1: x => x + 1 | 0
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
TAG: "Update",
|
|
54
|
+
_0: "a",
|
|
55
|
+
_1: x => x + 10 | 0
|
|
56
|
+
}
|
|
57
|
+
]);
|
|
58
|
+
let creates = Stdlib_Array.filterMap(optimized, a => {
|
|
59
|
+
if (typeof a !== "object" || a.TAG !== "Create") {
|
|
60
|
+
return;
|
|
61
|
+
} else {
|
|
62
|
+
return [
|
|
63
|
+
a._0,
|
|
64
|
+
a._1
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
globalThis.expect(creates).toEqual([[
|
|
69
|
+
"a",
|
|
70
|
+
11
|
|
71
|
+
]]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Regression tests for CommandPublisher (plan A7).
|
|
2
|
+
//
|
|
3
|
+
// Two bugs made the buffered publisher silently lose commands:
|
|
4
|
+
// - send() used the non-mutating `toSpliced`, so the buffer never drained and
|
|
5
|
+
// the wrong slice was published.
|
|
6
|
+
// - flush()'s in-flight arm looped on a dead condition and never sent.
|
|
7
|
+
// - clear() removed only the first buffered command instead of truncating.
|
|
8
|
+
//
|
|
9
|
+
// These tests drive the functor with a capturing publishCommands and assert the
|
|
10
|
+
// buffer flushes in order and clear() empties it.
|
|
11
|
+
|
|
12
|
+
open JestGlobals
|
|
13
|
+
|
|
14
|
+
S.enableJson()
|
|
15
|
+
|
|
16
|
+
let published: ref<array<Reventless.Message.commandJson>> = ref([])
|
|
17
|
+
|
|
18
|
+
module TestSpec = {
|
|
19
|
+
@schema let name = "TestCmd"
|
|
20
|
+
@schema type command = {value: int}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module TestConfig = {
|
|
24
|
+
let user = "tester"
|
|
25
|
+
let publishCommands: Task.publishCommands = (_name, cmds) => {
|
|
26
|
+
published := published.contents->Array.concat(cmds)
|
|
27
|
+
Promise.resolve()
|
|
28
|
+
}
|
|
29
|
+
let mode = CommandPublisher.SendAllInOneChunk
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module Pub = CommandPublisher.Make(TestSpec, TestConfig)
|
|
33
|
+
|
|
34
|
+
describe("CommandPublisher", () => {
|
|
35
|
+
testPromise("flush sends all buffered commands in order and drains the buffer", async () => {
|
|
36
|
+
published := []
|
|
37
|
+
Pub.publish("id1", {value: 1})
|
|
38
|
+
Pub.publish("id2", {value: 2})
|
|
39
|
+
await Pub.flush()
|
|
40
|
+
let ids = published.contents->Array.map((c: Reventless.Message.commandJson) => c.id)
|
|
41
|
+
expect(ids)->toEqual(["id1", "id2"])
|
|
42
|
+
// Buffer drained: a second flush sends nothing more.
|
|
43
|
+
published := []
|
|
44
|
+
await Pub.flush()
|
|
45
|
+
expect(published.contents->Array.length)->toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
testPromise("clear truncates the whole buffer so nothing is later published", async () => {
|
|
49
|
+
published := []
|
|
50
|
+
Pub.publish("id3", {value: 3})
|
|
51
|
+
Pub.publish("id4", {value: 4})
|
|
52
|
+
Pub.clear()
|
|
53
|
+
await Pub.flush()
|
|
54
|
+
expect(published.contents->Array.length)->toBe(0)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as CommandPublisher$ReventlessCore from "../../src/util/CommandPublisher.res.mjs";
|
|
5
|
+
|
|
6
|
+
S.enableJson();
|
|
7
|
+
|
|
8
|
+
let published = {
|
|
9
|
+
contents: []
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let commandSchema = S.schema(s => ({
|
|
13
|
+
value: s.m(S.int)
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
function commandAuthorization(param) {
|
|
17
|
+
return "AllowAuthenticated";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let TestSpec = {
|
|
21
|
+
name: "TestCmd",
|
|
22
|
+
commandSchema: commandSchema,
|
|
23
|
+
commandAuthorization: commandAuthorization
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function publishCommands(_name, cmds) {
|
|
27
|
+
published.contents = published.contents.concat(cmds);
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let TestConfig = {
|
|
32
|
+
user: "tester",
|
|
33
|
+
publishCommands: publishCommands,
|
|
34
|
+
mode: "SendAllInOneChunk"
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let Pub = CommandPublisher$ReventlessCore.Make(TestSpec)(TestConfig);
|
|
38
|
+
|
|
39
|
+
globalThis.describe("CommandPublisher", () => {
|
|
40
|
+
globalThis.test("flush sends all buffered commands in order and drains the buffer", async () => {
|
|
41
|
+
published.contents = [];
|
|
42
|
+
Pub.publish("id1", {
|
|
43
|
+
value: 1
|
|
44
|
+
});
|
|
45
|
+
Pub.publish("id2", {
|
|
46
|
+
value: 2
|
|
47
|
+
});
|
|
48
|
+
await Pub.flush();
|
|
49
|
+
let ids = published.contents.map(c => c.id);
|
|
50
|
+
globalThis.expect(ids).toEqual([
|
|
51
|
+
"id1",
|
|
52
|
+
"id2"
|
|
53
|
+
]);
|
|
54
|
+
published.contents = [];
|
|
55
|
+
await Pub.flush();
|
|
56
|
+
globalThis.expect(published.contents.length).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
globalThis.test("clear truncates the whole buffer so nothing is later published", async () => {
|
|
59
|
+
published.contents = [];
|
|
60
|
+
Pub.publish("id3", {
|
|
61
|
+
value: 3
|
|
62
|
+
});
|
|
63
|
+
Pub.publish("id4", {
|
|
64
|
+
value: 4
|
|
65
|
+
});
|
|
66
|
+
Pub.clear();
|
|
67
|
+
await Pub.flush();
|
|
68
|
+
globalThis.expect(published.contents.length).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
published,
|
|
74
|
+
TestSpec,
|
|
75
|
+
TestConfig,
|
|
76
|
+
Pub,
|
|
77
|
+
}
|
|
78
|
+
/* Not a pure module */
|