@reventlessdev/reventless-core 3.0.0-alpha.137 → 3.0.0-alpha.139

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 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.139 (2026-07-05)
7
+
8
+ ### Features
9
+
10
+ * **postgres:** add reventless-postgres backend + local-platform integration ([6913200](https://github.com/ReventlessDev/reventless-core/commit/69132001f9271e832a5af33416acd5b645feaf47))
11
+
12
+
13
+ # 3.0.0-alpha.138 (2026-07-04)
14
+
15
+ ### Features
16
+
17
+ * **core:** wire aggregate snapshots into the command callback (aggregate-snapshotting plan, step 5) ([09fda78](https://github.com/ReventlessDev/reventless-core/commit/09fda78a19bb758482111cebedfdb115d0ec7a35))
18
+
19
+
6
20
  # 3.0.0-alpha.137 (2026-07-03)
7
21
 
8
22
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.137",
3
+ "version": "3.0.0-alpha.139",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
32
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
33
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
34
34
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
35
- "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
35
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
37
- "@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
38
37
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
38
+ "@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
39
39
  "@reventlessdev/reventless-interop": "3.0.0-alpha.23",
40
40
  "@reventlessdev/reventless-spec": "3.0.0-alpha.64",
41
- "@reventlessdev/reventless-infra": "3.0.0-alpha.84"
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.85"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "^12.3.0",
@@ -122,6 +122,154 @@ module Make = (
122
122
 
123
123
  let resetCache = () => replayCache->Lru.clear
124
124
 
125
+ // Persisted-snapshot configuration (docs/plans/aggregate-snapshotting.md).
126
+ // `None` (the default) keeps full replay; `Some({interval, stateSchema})`
127
+ // seeds cold replays from the latest persisted snapshot and writes a fresh
128
+ // one every `interval` events. Snapshots are a read optimization only — the
129
+ // OCC append remains the sole consistency primitive, so a missing, drifted,
130
+ // or corrupt snapshot degrades silently to full replay.
131
+ let snapshotConfig = Behavior.snapshot
132
+
133
+ // Structural hash of the state schema, computed once. Stored on every written
134
+ // snapshot and compared on read: a snapshot whose hash differs from the
135
+ // current schema (a state-shape change since it was written) is ignored and
136
+ // overwritten at the next boundary, so a redeploy that changes `type state`
137
+ // can't decode stale bytes into the wrong shape. NOTE: this catches *shape*
138
+ // drift only — a semantics-only `evolve` change with an unchanged state type
139
+ // is invisible (see the plan's open question; for alpha, wipe snapshots on
140
+ // such changes, consistent with the alpha-wipe-over-migration convention).
141
+ let stateSchemaHash = switch snapshotConfig {
142
+ | Some({stateSchema}) =>
143
+ HashObj.hashDict(
144
+ ~dict=Dict.fromArray([("state", SchemaWalker.describeSchema(stateSchema->Obj.magic))]),
145
+ ~options={algorithm: SHA256},
146
+ )
147
+ | None => ""
148
+ }
149
+
150
+ // State ⇄ JSON via the configured stateSchema. Both swallow sury failures to
151
+ // `None` — an unserializable state just skips the snapshot write, and an
152
+ // undecodable snapshot falls back to full replay; neither can fail a command.
153
+ let encodeState = (state: Behavior.state): option<JSON.t> =>
154
+ switch snapshotConfig {
155
+ | Some({stateSchema}) =>
156
+ switch state->S.reverseConvertToJsonOrThrow(stateSchema) {
157
+ | json => Some(json)
158
+ | exception _ => None
159
+ }
160
+ | None => None
161
+ }
162
+
163
+ let decodeState = (json: JSON.t): option<Behavior.state> =>
164
+ switch snapshotConfig {
165
+ | Some({stateSchema}) =>
166
+ switch json->S.parseJsonOrThrow(stateSchema) {
167
+ | state => Some(state)
168
+ | exception _ => None
169
+ }
170
+ | None => None
171
+ }
172
+
173
+ // Cold read (in-process cache miss): when snapshots are enabled, seed from the
174
+ // latest persisted snapshot (hash-gated) and replay only the events after it;
175
+ // otherwise replay full history from seq 0. Returns `(state, sequenceNr)` where
176
+ // sequenceNr is the total event count (the OCC condition for the next append).
177
+ let coldReadState = id => {
178
+ let idStr = id->Spec.Id.toString
179
+ let seedEffect = switch snapshotConfig {
180
+ | None => Effect.succeed((Behavior.initialState, 0))
181
+ | Some(_) =>
182
+ Effect.promise(() => Ops.eventLog.latestSnapshot(id))
183
+ ->Effect.map(snapResult =>
184
+ switch snapResult {
185
+ | Ok(Some(snap)) if snap.EventLog.schemaHash == stateSchemaHash =>
186
+ switch decodeState(snap.state) {
187
+ | Some(state) => (state, snap.seqNr)
188
+ | None =>
189
+ EffectLogger.logWarn(
190
+ ~comp,
191
+ `snapshot ignored (undecodable): id=${idStr}, seq=${snap.seqNr->Int.toString} — full replay`,
192
+ )->Effect.runSync
193
+ (Behavior.initialState, 0)
194
+ }
195
+ | Ok(Some(snap)) =>
196
+ EffectLogger.logWarn(
197
+ ~comp,
198
+ `snapshot ignored (schema drift): id=${idStr}, seq=${snap.EventLog.seqNr->Int.toString} — full replay`,
199
+ )->Effect.runSync
200
+ (Behavior.initialState, 0)
201
+ | Ok(None) => (Behavior.initialState, 0)
202
+ | Error(msg) =>
203
+ EffectLogger.logWarn(
204
+ ~comp,
205
+ `snapshot read failed (ignored): id=${idStr}: ${msg} — full replay`,
206
+ )->Effect.runSync
207
+ (Behavior.initialState, 0)
208
+ }
209
+ )
210
+ }
211
+ seedEffect->Effect.flatMap(((seedState, seedSeq)) =>
212
+ Ops.eventLog.replayStream(id, ~fromSeq=seedSeq)
213
+ ->Stream.runFold((seedState, seedSeq), ((st, n), ev) => (Behavior.evolve(st, ev), n + 1))
214
+ ->Effect.tap(((_, n)) => {
215
+ let detail =
216
+ seedSeq > 0
217
+ ? `(snapshot@${seedSeq->Int.toString}, ${(n - seedSeq)->Int.toString} delta event(s))`
218
+ : `${n->Int.toString} event(s)`
219
+ EffectLogger.logInfo(~comp, `replay: id=${idStr}, ${detail}`)
220
+ })
221
+ )
222
+ }
223
+
224
+ // Fire-and-forget snapshot write — never awaited, never fails the command. A
225
+ // failed write just means the next cold replay reads a longer delta.
226
+ let fireSnapshotWrite = (id, idStr, snap: EventLog.snapshot) => {
227
+ let _ =
228
+ Ops.eventLog.writeSnapshot(id, snap)
229
+ ->Promise.then(result => {
230
+ switch result {
231
+ | Ok() =>
232
+ EffectLogger.logDebug(
233
+ ~comp,
234
+ `snapshot written: id=${idStr}, seq=${snap.seqNr->Int.toString}`,
235
+ )->Effect.runSync
236
+ | Error(msg) =>
237
+ EffectLogger.logWarn(
238
+ ~comp,
239
+ `snapshot write failed (ignored): id=${idStr}: ${msg}`,
240
+ )->Effect.runSync
241
+ }
242
+ Promise.resolve()
243
+ })
244
+ ->Promise.catch(err => {
245
+ let msg = err->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
246
+ EffectLogger.logWarn(
247
+ ~comp,
248
+ `snapshot write threw (ignored): id=${idStr}: ${msg}`,
249
+ )->Effect.runSync
250
+ Promise.resolve()
251
+ })
252
+ }
253
+
254
+ // Write a snapshot when this append crossed an `interval` boundary (a multiple
255
+ // of interval lies in (oldSeq, newSeq]). Keep-one: on a batch that crosses
256
+ // several boundaries we still write just the latest state at newSeq. No-op
257
+ // when snapshots are disabled or the state can't be serialized.
258
+ let maybeWriteSnapshot = (id, idStr, ~oldSeq, ~newSeq, state) =>
259
+ switch snapshotConfig {
260
+ | Some({interval}) if interval > 0 && newSeq / interval > oldSeq / interval =>
261
+ switch encodeState(state) {
262
+ | Some(stateJson) =>
263
+ fireSnapshotWrite(id, idStr, {EventLog.seqNr: newSeq, state: stateJson, schemaHash: stateSchemaHash})
264
+ | None =>
265
+ EffectLogger.logWarn(
266
+ ~comp,
267
+ `snapshot skipped (state not serializable): id=${idStr}, seq=${newSeq->Int.toString}`,
268
+ )->Effect.runSync
269
+ }
270
+ | _ => ()
271
+ }
272
+
125
273
  // Reports per-command outcomes on the inline side-channels and produces the
126
274
  // per-reference Ok/Error array consumed by SQS-style consumers. Domain rejections
127
275
  // are always Ok(reference) — SQS deletes the message because retry would not help —
@@ -160,20 +308,17 @@ module Make = (
160
308
  ) => {
161
309
  let idStr = id->Spec.Id.toString
162
310
 
163
- // Warm path: seed from the replay cache and skip the event-log read; the
164
- // OCC append below fences any staleness. Cold path: full replay as before.
311
+ // Warm path: seed from the in-process cache and skip both the snapshot read
312
+ // and the event-log replay; the OCC append below fences any staleness. Cold
313
+ // path (`coldReadState`): seed from the persisted snapshot when enabled, else
314
+ // full replay.
165
315
  let readState = switch replayCache->Lru.get(idStr) {
166
316
  | Some((state, seqNr)) =>
167
317
  EffectLogger.logInfo(
168
318
  ~comp,
169
319
  `replay skipped (cached): id=${idStr}, seq=${seqNr->Int.toString}`,
170
320
  )->Effect.map(_ => (state, seqNr))
171
- | None =>
172
- Ops.eventLog.replayStream(id)
173
- ->Stream.runFold((Behavior.initialState, 0), ((st, n), ev) => (Behavior.evolve(st, ev), n + 1))
174
- ->Effect.tap(((_, n)) =>
175
- EffectLogger.logInfo(~comp, `replay: id=${idStr}, ${n->Int.toString} event(s)`)
176
- )
321
+ | None => coldReadState(id)
177
322
  }
178
323
 
179
324
  readState
@@ -222,10 +367,11 @@ module Make = (
222
367
  | Ok(_) =>
223
368
  // The post-decide fold state IS the post-append replay result;
224
369
  // cache it so the next command for this id skips the replay.
225
- replayCache->Lru.put(
226
- idStr,
227
- (finalState, sequenceNr + generatedEvents'->Array.length),
228
- )
370
+ let newSeq = sequenceNr + generatedEvents'->Array.length
371
+ replayCache->Lru.put(idStr, (finalState, newSeq))
372
+ // Persist a snapshot if this append crossed an interval boundary
373
+ // (fire-and-forget — never blocks or fails the command).
374
+ maybeWriteSnapshot(id, idStr, ~oldSeq=sequenceNr, ~newSeq, finalState)
229
375
  let perRef = reportFinalOutcomes(
230
376
  outcomes,
231
377
  ~entityId=idStr,
@@ -1,13 +1,20 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import HashObject from "hash-object";
3
5
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
+ import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
4
8
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
9
  import * as Effect from "effect/Effect";
6
10
  import * as Stream from "effect/Stream";
11
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
12
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
7
13
  import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
8
14
  import * as Message$ReventlessCore from "../../Message.res.mjs";
9
15
  import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
10
16
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
17
+ import * as SchemaWalker$ReventlessCore from "../../plugin/component/SchemaWalker.res.mjs";
11
18
  import * as CommandTopic_Helpers$ReventlessCore from "../CommandTopic/CommandTopic_Helpers.res.mjs";
12
19
 
13
20
  function Make(Spec) {
@@ -67,6 +74,125 @@ function Make(Spec) {
67
74
  };
68
75
  let replayCache = Lru$ReventlessCore.make(100);
69
76
  let resetCache = () => Lru$ReventlessCore.clear(replayCache);
77
+ let snapshotConfig = Behavior.snapshot;
78
+ let stateSchemaHash = snapshotConfig !== undefined ? HashObject(Object.fromEntries([[
79
+ "state",
80
+ SchemaWalker$ReventlessCore.describeSchema(snapshotConfig.stateSchema)
81
+ ]]), {
82
+ algorithm: "sha256"
83
+ }) : "";
84
+ let encodeState = state => {
85
+ if (snapshotConfig === undefined) {
86
+ return;
87
+ }
88
+ let json;
89
+ try {
90
+ json = S.reverseConvertToJsonOrThrow(state, snapshotConfig.stateSchema);
91
+ } catch (exn) {
92
+ return;
93
+ }
94
+ return json;
95
+ };
96
+ let decodeState = json => {
97
+ if (snapshotConfig === undefined) {
98
+ return;
99
+ }
100
+ let state;
101
+ try {
102
+ state = S.parseJsonOrThrow(json, snapshotConfig.stateSchema);
103
+ } catch (exn) {
104
+ return;
105
+ }
106
+ return Primitive_option.some(state);
107
+ };
108
+ let coldReadState = id => {
109
+ let idStr = Spec.Id.toString(id);
110
+ let seedEffect = snapshotConfig !== undefined ? Effect.map(Effect.promise(() => Ops.eventLog.latestSnapshot(id)), snapResult => {
111
+ if (snapResult.TAG === "Ok") {
112
+ let snap = snapResult._0;
113
+ if (snap === undefined) {
114
+ return [
115
+ Behavior.initialState,
116
+ 0
117
+ ];
118
+ }
119
+ if (snap.schemaHash === stateSchemaHash) {
120
+ let state = decodeState(snap.state);
121
+ if (state !== undefined) {
122
+ return [
123
+ Primitive_option.valFromOption(state),
124
+ snap.seqNr
125
+ ];
126
+ } else {
127
+ Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot ignored (undecodable): id=` + idStr + `, seq=` + snap.seqNr.toString() + ` — full replay`));
128
+ return [
129
+ Behavior.initialState,
130
+ 0
131
+ ];
132
+ }
133
+ }
134
+ Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot ignored (schema drift): id=` + idStr + `, seq=` + snap.seqNr.toString() + ` — full replay`));
135
+ return [
136
+ Behavior.initialState,
137
+ 0
138
+ ];
139
+ }
140
+ Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot read failed (ignored): id=` + idStr + `: ` + snapResult._0 + ` — full replay`));
141
+ return [
142
+ Behavior.initialState,
143
+ 0
144
+ ];
145
+ }) : Effect.succeed([
146
+ Behavior.initialState,
147
+ 0
148
+ ]);
149
+ return Effect.flatMap(seedEffect, param => {
150
+ let seedSeq = param[1];
151
+ return Effect.tap(Stream.runFold(Ops.eventLog.replayStream(id, seedSeq), [
152
+ param[0],
153
+ seedSeq
154
+ ], (param, ev) => [
155
+ Behavior.evolve(param[0], ev),
156
+ param[1] + 1 | 0
157
+ ]), param => {
158
+ let n = param[1];
159
+ let detail = seedSeq > 0 ? `(snapshot@` + seedSeq.toString() + `, ` + (n - seedSeq | 0).toString() + ` delta event(s))` : n.toString() + ` event(s)`;
160
+ return EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay: id=` + idStr + `, ` + detail);
161
+ });
162
+ });
163
+ };
164
+ let maybeWriteSnapshot = (id, idStr, oldSeq, newSeq, state) => {
165
+ if (snapshotConfig === undefined) {
166
+ return;
167
+ }
168
+ let interval = snapshotConfig.interval;
169
+ if (!(interval > 0 && Primitive_int.div(newSeq, interval) > Primitive_int.div(oldSeq, interval))) {
170
+ return;
171
+ }
172
+ let stateJson = encodeState(state);
173
+ if (stateJson !== undefined) {
174
+ let snap = {
175
+ seqNr: newSeq,
176
+ state: stateJson,
177
+ schemaHash: stateSchemaHash
178
+ };
179
+ Stdlib_Promise.$$catch(Ops.eventLog.writeSnapshot(id, snap).then(result => {
180
+ if (result.TAG === "Ok") {
181
+ Effect.runSync(EffectLogger$ReventlessCore.logDebug(comp, undefined, `snapshot written: id=` + idStr + `, seq=` + snap.seqNr.toString()));
182
+ } else {
183
+ Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot write failed (ignored): id=` + idStr + `: ` + result._0));
184
+ }
185
+ return Promise.resolve();
186
+ }), err => {
187
+ let msg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(err), Stdlib_JsExn.message), "unknown");
188
+ Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot write threw (ignored): id=` + idStr + `: ` + msg));
189
+ return Promise.resolve();
190
+ });
191
+ return;
192
+ } else {
193
+ return Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot skipped (state not serializable): id=` + idStr + `, seq=` + newSeq.toString()));
194
+ }
195
+ };
70
196
  let reportFinalOutcomes = (outcomes, entityId, appendSucceeded, appendedEventCount, appendErrorDetailOpt) => {
71
197
  let appendErrorDetail = appendErrorDetailOpt !== undefined ? appendErrorDetailOpt : "";
72
198
  return outcomes.map(param => {
@@ -115,13 +241,7 @@ function Make(Spec) {
115
241
  seqNr
116
242
  ]);
117
243
  } else {
118
- readState = Effect.tap(Stream.runFold(Ops.eventLog.replayStream(id, undefined), [
119
- Behavior.initialState,
120
- 0
121
- ], (param, ev) => [
122
- Behavior.evolve(param[0], ev),
123
- param[1] + 1 | 0
124
- ]), param => EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay: id=` + idStr + `, ` + param[1].toString() + ` event(s)`));
244
+ readState = coldReadState(id);
125
245
  }
126
246
  return Effect.flatMap(readState, param => {
127
247
  let sequenceNr = param[1];
@@ -156,10 +276,12 @@ function Make(Spec) {
156
276
  let eventJsons = eventsToAppend.map(event$p => Message$ReventlessCore.encode(event$p.event, Spec.eventSchema));
157
277
  return Effect.flatMap(Effect.flatMap(EffectLogger$ReventlessCore.logInfo(comp, eventJsons, `produced ` + eventCount + ` event(s): [` + eventDetails + `]`), () => Effect.promise(() => Ops.eventLog.append(sequenceNr, id, eventsToAppend))), appendResult => {
158
278
  if (appendResult.TAG === "Ok") {
279
+ let newSeq = sequenceNr + eventsToAppend.length | 0;
159
280
  Lru$ReventlessCore.put(replayCache, idStr, [
160
281
  finalState,
161
- sequenceNr + eventsToAppend.length | 0
282
+ newSeq
162
283
  ]);
284
+ maybeWriteSnapshot(id, idStr, sequenceNr, newSeq, finalState);
163
285
  let perRef = reportFinalOutcomes(outcomes, idStr, true, eventsToAppend.length, undefined);
164
286
  return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `append: id=` + idStr), () => ({
165
287
  TAG: "Ok",
@@ -234,4 +356,4 @@ function Make(Spec) {
234
356
  export {
235
357
  Make,
236
358
  }
237
- /* effect/Effect Not a pure module */
359
+ /* S Not a pure module */
@@ -27,7 +27,7 @@ type operations = {
27
27
  append: (
28
28
  array<rawStoredEvent>,
29
29
  ~condition: Reventless.DcbTag.appendCondition=?,
30
- ) => promise<result<Reventless.DcbTag.sequencePosition, string>>,
30
+ ) => promise<result<Reventless.DcbTag.sequencePosition, ReventlessInfra.DcbEventLog.appendError>>,
31
31
  readStream: (
32
32
  ~query: Reventless.DcbTag.query,
33
33
  ~after: Reventless.DcbTag.sequencePosition=?,
@@ -358,6 +358,12 @@ module Make = (
358
358
  _ => Ok("ok"),
359
359
  )
360
360
  | Error(err) =>
361
+ // Typed classification (was substring-matching the error string,
362
+ // which disagreed with the backends' casing).
363
+ let (errorCode, errDetail) = switch err {
364
+ | ReventlessInfra.DcbEventLog.Conflict => ("Conflict", "conflict: condition check failed")
365
+ | StorageFailure(msg) => ("AppendFailed", msg)
366
+ }
361
367
  if retries > 0 {
362
368
  // Re-seed from the just-read snapshot so the retry reads only
363
369
  // the delta the conflicting writer added, not full history.
@@ -369,13 +375,12 @@ module Make = (
369
375
  EffectLogger.logWarn(
370
376
  ~comp,
371
377
  `append failed (retrying ${(maxRetries - retries + 1)
372
- ->Int.toString}/${maxRetries->Int.toString}): ${err}`,
378
+ ->Int.toString}/${maxRetries->Int.toString}): ${errDetail}`,
373
379
  )->Effect.flatMap(_ => attempt(~retries=retries - 1))
374
380
  } else {
375
381
  // Retries exhausted — drop any cached snapshot so the next
376
382
  // command for this entity takes the cold full-read path.
377
383
  cacheInvalidate()
378
- let errorCode = err->String.startsWith("Conflict") ? "Conflict" : "AppendFailed"
379
384
  // A surfaced Conflict means the 3-retry loop could not absorb
380
385
  // the contention — the operator signal for "this slice is hot"
381
386
  // (consider sharding / async — Issue 10, §4 of the analysis).
@@ -384,12 +389,12 @@ module Make = (
384
389
  }
385
390
  CommandTopic_Helpers.reportRejected(
386
391
  cmdJson.meta.msgId,
387
- {errorCode, errorDetail: err},
392
+ {errorCode, errorDetail: errDetail},
388
393
  )
389
394
  EffectLogger.logError(
390
395
  ~comp,
391
- `append failed, retries exhausted: ${err}`,
392
- )->Effect.map(_ => Error(err))
396
+ `append failed, retries exhausted: ${errDetail}`,
397
+ )->Effect.map(_ => Error(errDetail))
393
398
  }
394
399
  },
395
400
  )
@@ -235,27 +235,37 @@ function Make(Spec) {
235
235
  }));
236
236
  }
237
237
  let err = appendResult._0;
238
+ let match;
239
+ match = typeof err !== "object" ? [
240
+ "Conflict",
241
+ "conflict: condition check failed"
242
+ ] : [
243
+ "AppendFailed",
244
+ err._0
245
+ ];
246
+ let errDetail = match[1];
247
+ let errorCode = match[0];
238
248
  if (retries > 0) {
239
249
  seed.contents = [
240
250
  state,
241
251
  headPosition
242
252
  ];
243
253
  Metrics$ReventlessCore.emitCount("AppendRetry", Spec.name, undefined);
244
- return Effect.flatMap(EffectLogger$ReventlessCore.logWarn(comp, undefined, `append failed (retrying ` + ((3 - retries | 0) + 1 | 0).toString() + `/` + (3).toString() + `): ` + err), () => attempt(retries - 1 | 0));
245
- }
246
- cacheInvalidate();
247
- let errorCode = err.startsWith("Conflict") ? "Conflict" : "AppendFailed";
248
- if (errorCode === "Conflict") {
249
- Metrics$ReventlessCore.emitCount("AppendConflict", Spec.name, undefined);
254
+ return Effect.flatMap(EffectLogger$ReventlessCore.logWarn(comp, undefined, `append failed (retrying ` + ((3 - retries | 0) + 1 | 0).toString() + `/` + (3).toString() + `): ` + errDetail), () => attempt(retries - 1 | 0));
255
+ } else {
256
+ cacheInvalidate();
257
+ if (errorCode === "Conflict") {
258
+ Metrics$ReventlessCore.emitCount("AppendConflict", Spec.name, undefined);
259
+ }
260
+ CommandTopic_Helpers$ReventlessCore.reportRejected(cmdJson.meta.msgId, {
261
+ errorCode: errorCode,
262
+ errorDetail: errDetail
263
+ });
264
+ return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed, retries exhausted: ` + errDetail), () => ({
265
+ TAG: "Error",
266
+ _0: errDetail
267
+ }));
250
268
  }
251
- CommandTopic_Helpers$ReventlessCore.reportRejected(cmdJson.meta.msgId, {
252
- errorCode: errorCode,
253
- errorDetail: err
254
- });
255
- return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed, retries exhausted: ` + err), () => ({
256
- TAG: "Error",
257
- _0: err
258
- }));
259
269
  });
260
270
  }
261
271
  cachePut([