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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +7 -7
  3. package/src/Message.res +43 -10
  4. package/src/Message.res.mjs +21 -4
  5. package/src/Projection.res +65 -46
  6. package/src/Projection.res.mjs +53 -49
  7. package/src/components/Aggregate/Aggregate_Callback.res +207 -6
  8. package/src/components/Aggregate/Aggregate_Callback.res.mjs +160 -10
  9. package/src/components/DcbEventLog/DcbEventLog_Operations.res +3 -0
  10. package/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs +3 -1
  11. package/src/components/EventLog/EventLog.res +20 -1
  12. package/src/components/EventLog/EventLog_Adapter.res +3 -1
  13. package/src/components/EventLog/EventLog_Builder.res +4 -0
  14. package/src/components/EventLog/EventLog_Builder.res.mjs +3 -1
  15. package/src/components/EventLog/EventLog_Operations.res +11 -2
  16. package/src/components/EventLog/EventLog_Operations.res.mjs +6 -2
  17. package/src/components/EventLog/EventPublish_Callback.res +7 -0
  18. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +26 -6
  19. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +21 -6
  20. package/src/plugin/lifecycle/PluginBehavior.res +6 -0
  21. package/src/plugin/lifecycle/PluginBehavior.res.mjs +3 -0
  22. package/src/util/Logger.res +10 -0
  23. package/src/util/Logger.res.mjs +12 -2
  24. package/tests/aggregate/AggregateCacheTest.res +271 -0
  25. package/tests/aggregate/AggregateCacheTest.res.mjs +433 -0
  26. package/tests/aggregate/AggregateCallbackTest.res +4 -1
  27. package/tests/aggregate/AggregateCallbackTest.res.mjs +4 -1
  28. package/tests/aggregate/AggregateCausationTest.res +4 -1
  29. package/tests/aggregate/AggregateCausationTest.res.mjs +4 -1
  30. package/tests/aggregate/AggregateConflictTest.res +14 -3
  31. package/tests/aggregate/AggregateConflictTest.res.mjs +26 -3
  32. package/tests/aggregate/AggregateFixtures.res +10 -2
  33. package/tests/aggregate/AggregateFixtures.res.mjs +22 -2
  34. package/tests/aggregate/AggregateRejectionTest.res +4 -1
  35. package/tests/aggregate/AggregateRejectionTest.res.mjs +4 -1
  36. package/tests/aggregate/AggregateSnapshotTest.res +357 -0
  37. package/tests/aggregate/AggregateSnapshotTest.res.mjs +645 -0
  38. package/tests/eventlog/EventLogFixtures.res +13 -2
  39. package/tests/eventlog/EventLogFixtures.res.mjs +27 -3
  40. package/tests/logger/LoggerLazyTest.res +36 -0
  41. package/tests/logger/LoggerLazyTest.res.mjs +40 -0
@@ -7,6 +7,11 @@ module type Ops = {
7
7
  module type T = {
8
8
  module Spec: Reventless.Aggregate.Spec
9
9
  let handleCommands: CommandTopic.commandsHandler<Message.command'<Spec.Id.t, Spec.command>>
10
+
11
+ /** Flushes this aggregate's in-process replay cache. The cache is a pure read
12
+ optimization (the OCC append fences staleness), so this is only needed for
13
+ test isolation — production code never has to call it. */
14
+ let resetCache: unit => unit
10
15
  }
11
16
 
12
17
  module Make = (
@@ -95,6 +100,176 @@ module Make = (
95
100
 
96
101
  let maxConflictRetries = 3
97
102
 
103
+ // In-process replay cache (per warm Lambda instance, per aggregate — the
104
+ // functor instance owns the cache, so the event log is implicit and the key
105
+ // is just the aggregate id). Holds the `(state, sequenceNr)` the previous
106
+ // command batch for that id left behind.
107
+ //
108
+ // On a hit, `replayProcessAppend` skips the event-log replay entirely and
109
+ // decides on the cached state; after a successful append it stores the
110
+ // already-folded post-append state (aggregates have no consumed-vs-produced
111
+ // event split, so `evolve` over the appended events IS the replay result).
112
+ // Correctness rests on the append's optimistic-concurrency check: a stale
113
+ // cached state only changes the *decision*, and a stale sequenceNr conflicts
114
+ // at append time → the conflict branch invalidates the entry and the retry
115
+ // replays cold. Mirrors the StateChangeSlice decision-model cache, including
116
+ // the fixed capacity (a per-aggregate knob is a future refinement — see
117
+ // docs/plans/aggregate-snapshotting.md).
118
+ let replayCacheCapacity = 100
119
+ let replayCache: Lru.t<string, (Behavior.state, int)> = Lru.make(
120
+ ~capacity=replayCacheCapacity,
121
+ )
122
+
123
+ let resetCache = () => replayCache->Lru.clear
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
+
98
273
  // Reports per-command outcomes on the inline side-channels and produces the
99
274
  // per-reference Ok/Error array consumed by SQS-style consumers. Domain rejections
100
275
  // are always Ok(reference) — SQS deletes the message because retry would not help —
@@ -133,13 +308,22 @@ module Make = (
133
308
  ) => {
134
309
  let idStr = id->Spec.Id.toString
135
310
 
136
- Ops.eventLog.replayStream(id)
137
- ->Stream.runFold((Behavior.initialState, 0), ((st, n), ev) => (Behavior.evolve(st, ev), n + 1))
138
- ->Effect.tap(((_, n)) =>
139
- EffectLogger.logInfo(~comp, `replay: id=${idStr}, ${n->Int.toString} event(s)`)
140
- )
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.
315
+ let readState = switch replayCache->Lru.get(idStr) {
316
+ | Some((state, seqNr)) =>
317
+ EffectLogger.logInfo(
318
+ ~comp,
319
+ `replay skipped (cached): id=${idStr}, seq=${seqNr->Int.toString}`,
320
+ )->Effect.map(_ => (state, seqNr))
321
+ | None => coldReadState(id)
322
+ }
323
+
324
+ readState
141
325
  ->Effect.flatMap(((initialState, sequenceNr)) => {
142
- let (_finalState, outcomes) =
326
+ let (finalState, outcomes) =
143
327
  topicItemsForId->Array.reduce((initialState, []), processCommand)
144
328
 
145
329
  let eventsToAppend =
@@ -154,6 +338,9 @@ module Make = (
154
338
 
155
339
  switch eventsToAppend {
156
340
  | [] =>
341
+ // Nothing appended, but the read snapshot is valid — keep it warm for
342
+ // the next command (also refreshes recency on a cache hit).
343
+ replayCache->Lru.put(idStr, (initialState, sequenceNr))
157
344
  let perRef = reportFinalOutcomes(
158
345
  outcomes,
159
346
  ~entityId=idStr,
@@ -178,6 +365,13 @@ module Make = (
178
365
  appendResult =>
179
366
  switch appendResult {
180
367
  | Ok(_) =>
368
+ // The post-decide fold state IS the post-append replay result;
369
+ // cache it so the next command for this id skips the replay.
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)
181
375
  let perRef = reportFinalOutcomes(
182
376
  outcomes,
183
377
  ~entityId=idStr,
@@ -186,6 +380,9 @@ module Make = (
186
380
  )
187
381
  EffectLogger.logInfo(~comp, `append: id=${idStr}`)->Effect.map(_ => Ok(perRef))
188
382
  | Error(EventLog.Conflict) =>
383
+ // Another writer advanced the stream past our (possibly cached)
384
+ // sequenceNr — drop the entry so the retry replays cold.
385
+ replayCache->Lru.invalidate(idStr)
189
386
  // Signal the outer replay+re-decide retry loop (which matches
190
387
  // Error(_)); the string is an internal marker, no longer a
191
388
  // cross-component substring sentinel.
@@ -193,6 +390,10 @@ module Make = (
193
390
  _ => Error("conflict"),
194
391
  )
195
392
  | Error(EventLog.StorageFailure(msg)) =>
393
+ // Appends are atomic, so the read snapshot is likely still valid —
394
+ // but a storage error means we can't be sure what committed;
395
+ // drop the entry so the next attempt reads authoritative state.
396
+ replayCache->Lru.invalidate(idStr)
196
397
  let perRef = reportFinalOutcomes(
197
398
  outcomes,
198
399
  ~entityId=idStr,
@@ -1,12 +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";
13
+ import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
7
14
  import * as Message$ReventlessCore from "../../Message.res.mjs";
8
15
  import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
9
16
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
17
+ import * as SchemaWalker$ReventlessCore from "../../plugin/component/SchemaWalker.res.mjs";
10
18
  import * as CommandTopic_Helpers$ReventlessCore from "../CommandTopic/CommandTopic_Helpers.res.mjs";
11
19
 
12
20
  function Make(Spec) {
@@ -64,6 +72,127 @@ function Make(Spec) {
64
72
  ]])
65
73
  ];
66
74
  };
75
+ let replayCache = Lru$ReventlessCore.make(100);
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
+ };
67
196
  let reportFinalOutcomes = (outcomes, entityId, appendSucceeded, appendedEventCount, appendErrorDetailOpt) => {
68
197
  let appendErrorDetail = appendErrorDetailOpt !== undefined ? appendErrorDetailOpt : "";
69
198
  return outcomes.map(param => {
@@ -102,19 +231,27 @@ function Make(Spec) {
102
231
  };
103
232
  let replayProcessAppend = (id, topicItemsForId) => {
104
233
  let idStr = Spec.Id.toString(id);
105
- return Effect.flatMap(Effect.tap(Stream.runFold(Ops.eventLog.replayStream(id), [
106
- Behavior.initialState,
107
- 0
108
- ], (param, ev) => [
109
- Behavior.evolve(param[0], ev),
110
- param[1] + 1 | 0
111
- ]), param => EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay: id=` + idStr + `, ` + param[1].toString() + ` event(s)`)), param => {
234
+ let match = Lru$ReventlessCore.get(replayCache, idStr);
235
+ let readState;
236
+ if (match !== undefined) {
237
+ let seqNr = match[1];
238
+ let state = match[0];
239
+ readState = Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay skipped (cached): id=` + idStr + `, seq=` + seqNr.toString()), () => [
240
+ state,
241
+ seqNr
242
+ ]);
243
+ } else {
244
+ readState = coldReadState(id);
245
+ }
246
+ return Effect.flatMap(readState, param => {
112
247
  let sequenceNr = param[1];
248
+ let initialState = param[0];
113
249
  let match = Stdlib_Array.reduce(topicItemsForId, [
114
- param[0],
250
+ initialState,
115
251
  []
116
252
  ], processCommand);
117
253
  let outcomes = match[1];
254
+ let finalState = match[0];
118
255
  let eventsToAppend = outcomes.map(param => {
119
256
  let meta = param[2];
120
257
  let outcome = param[1];
@@ -139,6 +276,12 @@ function Make(Spec) {
139
276
  let eventJsons = eventsToAppend.map(event$p => Message$ReventlessCore.encode(event$p.event, Spec.eventSchema));
140
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 => {
141
278
  if (appendResult.TAG === "Ok") {
279
+ let newSeq = sequenceNr + eventsToAppend.length | 0;
280
+ Lru$ReventlessCore.put(replayCache, idStr, [
281
+ finalState,
282
+ newSeq
283
+ ]);
284
+ maybeWriteSnapshot(id, idStr, sequenceNr, newSeq, finalState);
142
285
  let perRef = reportFinalOutcomes(outcomes, idStr, true, eventsToAppend.length, undefined);
143
286
  return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `append: id=` + idStr), () => ({
144
287
  TAG: "Ok",
@@ -147,12 +290,14 @@ function Make(Spec) {
147
290
  }
148
291
  let msg = appendResult._0;
149
292
  if (typeof msg !== "object") {
293
+ Lru$ReventlessCore.invalidate(replayCache, idStr);
150
294
  return Effect.map(EffectLogger$ReventlessCore.logWarn(comp, undefined, `conflict: id=` + idStr + `, will retry`), () => ({
151
295
  TAG: "Error",
152
296
  _0: "conflict"
153
297
  }));
154
298
  }
155
299
  let msg$1 = msg._0;
300
+ Lru$ReventlessCore.invalidate(replayCache, idStr);
156
301
  let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg$1);
157
302
  return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg$1), () => ({
158
303
  TAG: "Ok",
@@ -160,6 +305,10 @@ function Make(Spec) {
160
305
  }));
161
306
  });
162
307
  }
308
+ Lru$ReventlessCore.put(replayCache, idStr, [
309
+ initialState,
310
+ sequenceNr
311
+ ]);
163
312
  let perRef = reportFinalOutcomes(outcomes, idStr, true, 0, undefined);
164
313
  return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `no events produced: id=` + idStr), () => ({
165
314
  TAG: "Ok",
@@ -198,7 +347,8 @@ function Make(Spec) {
198
347
  }), prim => prim.flat()));
199
348
  return {
200
349
  Spec: Spec,
201
- handleCommands: handleCommands
350
+ handleCommands: handleCommands,
351
+ resetCache: resetCache
202
352
  };
203
353
  });
204
354
  }
@@ -206,4 +356,4 @@ function Make(Spec) {
206
356
  export {
207
357
  Make,
208
358
  }
209
- /* effect/Effect Not a pure module */
359
+ /* S Not a pure module */
@@ -92,6 +92,9 @@ module Make = (Ops: Ops): T => {
92
92
  eventCount: finalRawEventsJson->Array.length,
93
93
  eventsJson: finalRawEventsJson,
94
94
  meta: representativeMeta,
95
+ // Bare payloads carry no meta inline — expose the per-event metas so
96
+ // afterPublish consumers can correlate each published event.
97
+ metas: rawEvents->Array.map(re => re.meta),
95
98
  }
96
99
  let _ = await hook(published)
97
100
  } catch {
@@ -62,12 +62,14 @@ function Make(Ops) {
62
62
  }
63
63
  try {
64
64
  let published_eventCount$1 = finalRawEventsJson.length;
65
+ let published_metas = rawEvents.map(re => re.meta);
65
66
  let published$1 = {
66
67
  componentName: name,
67
68
  entityId: name,
68
69
  eventCount: published_eventCount$1,
69
70
  eventsJson: finalRawEventsJson,
70
- meta: representativeMeta
71
+ meta: representativeMeta,
72
+ metas: published_metas
71
73
  };
72
74
  await hook$1(published$1);
73
75
  return;
@@ -19,10 +19,27 @@ type appendError =
19
19
  | Conflict
20
20
  | StorageFailure(string)
21
21
 
22
+ // A persisted aggregate-state snapshot: `state` is the fold of events
23
+ // seq 0..seqNr-1, so `seqNr` doubles as the sequence number to resume the
24
+ // replay from (and the OCC condition for the next append). `schemaHash` gates
25
+ // staleness — consumers ignore a snapshot whose hash differs from their current
26
+ // state schema and fall back to full replay. Snapshots are a read optimization
27
+ // only; the OCC append remains the sole consistency primitive
28
+ // (docs/plans/aggregate-snapshotting.md).
29
+ type snapshot = {seqNr: int, state: JSON.t, schemaHash: string}
30
+
22
31
  type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, appendError>>
23
32
  type replay<'id, 'event> = 'id => promise<array<'event>>
24
- type replayStream<'id, 'event> = 'id => Stream.t<'event, string, unit>
33
+ // `fromSeq` starts the replay at that sequence number (inclusive; default 0) —
34
+ // the delta read after seeding from a snapshot at seqNr = fromSeq.
35
+ type replayStream<'id, 'event> = ('id, ~fromSeq: int=?) => Stream.t<'event, string, unit>
25
36
  type appendStream<'id, 'event> = (int, 'id, Stream.t<'event, string, unit>) => Effect.t<unit, string, unit>
37
+ // Keep-one semantics: `writeSnapshot` overwrites the single snapshot per
38
+ // aggregate; recovery from a corrupt snapshot is full replay, not older
39
+ // snapshots. Failures are plain strings — a snapshot op failure must never
40
+ // fail a command, so no retryable/typed error channel is needed.
41
+ type latestSnapshot<'id> = 'id => promise<result<option<snapshot>, string>>
42
+ type writeSnapshot<'id> = ('id, snapshot) => promise<result<unit, string>>
26
43
 
27
44
  module type T = {
28
45
  module Spec: ReventlessInfra.EventLog.T
@@ -32,6 +49,8 @@ module type T = {
32
49
  replay: replay<Spec.Id.t, Spec.event>,
33
50
  replayStream: replayStream<Spec.Id.t, Spec.event>,
34
51
  appendStream: appendStream<Spec.Id.t, Spec.event>,
52
+ latestSnapshot: latestSnapshot<Spec.Id.t>,
53
+ writeSnapshot: writeSnapshot<Spec.Id.t>,
35
54
  }
36
55
  type component = component<operations>
37
56
 
@@ -1,8 +1,10 @@
1
1
  type operations = {
2
2
  append: EventLog.append<string, JSON.t>,
3
3
  replay: EventLog.replay<string, JSON.t>,
4
- replayStream: string => Stream.t<JSON.t, string, unit>,
4
+ replayStream: (string, ~fromSeq: int=?) => Stream.t<JSON.t, string, unit>,
5
5
  appendStream: EventLog.appendStream<string, JSON.t>,
6
+ latestSnapshot: EventLog.latestSnapshot<string>,
7
+ writeSnapshot: EventLog.writeSnapshot<string>,
6
8
  }
7
9
  type storage = {
8
10
  resources: array<ReventlessInfra.Adapter.resource>,
@@ -10,6 +10,8 @@ module Make = (
10
10
  replay: EventLog.replay<Spec.Id.t, Spec.event>,
11
11
  replayStream: EventLog.replayStream<Spec.Id.t, Spec.event>,
12
12
  appendStream: EventLog.appendStream<Spec.Id.t, Spec.event>,
13
+ latestSnapshot: EventLog.latestSnapshot<Spec.Id.t>,
14
+ writeSnapshot: EventLog.writeSnapshot<Spec.Id.t>,
13
15
  }
14
16
  type component = Component.t<EventLog.t, EventLog.outputs, operations>
15
17
 
@@ -44,6 +46,8 @@ module Make = (
44
46
  replay: Ops.replay,
45
47
  replayStream: Ops.replayStream,
46
48
  appendStream: Ops.appendStream,
49
+ latestSnapshot: Ops.latestSnapshot,
50
+ writeSnapshot: Ops.writeSnapshot,
47
51
  }
48
52
  }),
49
53
  )
@@ -32,7 +32,9 @@ function Make(Spec) {
32
32
  append: Ops.append,
33
33
  replay: Ops.replay,
34
34
  replayStream: Ops.replayStream,
35
- appendStream: Ops.appendStream
35
+ appendStream: Ops.appendStream,
36
+ latestSnapshot: Ops.latestSnapshot,
37
+ writeSnapshot: Ops.writeSnapshot
36
38
  };
37
39
  }));
38
40
  let outputs_resources = storage.resources;
@@ -11,6 +11,8 @@ module type T = {
11
11
  let replay: EventLog.replay<Spec.Id.t, Spec.event>
12
12
  let replayStream: EventLog.replayStream<Spec.Id.t, Spec.event>
13
13
  let appendStream: EventLog.appendStream<Spec.Id.t, Spec.event>
14
+ let latestSnapshot: EventLog.latestSnapshot<Spec.Id.t>
15
+ let writeSnapshot: EventLog.writeSnapshot<Spec.Id.t>
14
16
  }
15
17
 
16
18
  // Retry schedule for transient storage errors.
@@ -187,12 +189,19 @@ module Make = (Spec: ReventlessInfra.EventLog.T, Ops: Ops with module Spec = Spe
187
189
 
188
190
  // Lazy streaming replay — wraps decodeEvent in Effect.sync so thrown exceptions
189
191
  // surface through the stream's error channel rather than as unhandled exceptions.
190
- let replayStream = id =>
191
- Ops.storage.replayStream(id->Spec.Id.toString)
192
+ let replayStream = (id, ~fromSeq=?) =>
193
+ Ops.storage.replayStream(id->Spec.Id.toString, ~fromSeq=?fromSeq)
192
194
  ->Stream.mapEffect(json =>
193
195
  Effect.sync(() => decodeEvent(id->Spec.Id.toString, json))
194
196
  )
195
197
 
198
+ // Snapshot passthrough — the state stays JSON at this layer; the consumer
199
+ // (Aggregate_Callback) decodes it against its own state schema and hash-gates
200
+ // staleness, so a drifted snapshot degrades to full replay, never to an error.
201
+ let latestSnapshot = id => Ops.storage.latestSnapshot(id->Spec.Id.toString)
202
+
203
+ let writeSnapshot = (id, snap) => Ops.storage.writeSnapshot(id->Spec.Id.toString, snap)
204
+
196
205
  // Streaming append — encodes each Spec.event to the {type, data} storage format
197
206
  // and writes sequentially via the storage adapter.
198
207
  // Accepts Spec.event items (symmetric with replayStream) to enable direct
@@ -186,7 +186,9 @@ function Make(Spec) {
186
186
  let id$1 = Spec.Id.toString(id);
187
187
  return eventsJson.map(json => decodeEvent(id$1, json));
188
188
  };
189
- let replayStream = id => Stream.mapEffect(Ops.storage.replayStream(Spec.Id.toString(id)), json => Effect$1.sync(() => decodeEvent(Spec.Id.toString(id), json)));
189
+ let replayStream = (id, fromSeq) => Stream.mapEffect(Ops.storage.replayStream(Spec.Id.toString(id), fromSeq), json => Effect$1.sync(() => decodeEvent(Spec.Id.toString(id), json)));
190
+ let latestSnapshot = id => Ops.storage.latestSnapshot(Spec.Id.toString(id));
191
+ let writeSnapshot = (id, snap) => Ops.storage.writeSnapshot(Spec.Id.toString(id), snap);
190
192
  let appendStream = (startingSeqNr, id, stream) => Ops.storage.appendStream(startingSeqNr, Spec.Id.toString(id), Stream.map(stream, event => {
191
193
  let json = Message$ReventlessCore.encode(event, Spec.eventSchema);
192
194
  let match = Message$ReventlessCore.splitMessage(json);
@@ -206,7 +208,9 @@ function Make(Spec) {
206
208
  append: append,
207
209
  replay: replay,
208
210
  replayStream: replayStream,
209
- appendStream: appendStream
211
+ appendStream: appendStream,
212
+ latestSnapshot: latestSnapshot,
213
+ writeSnapshot: writeSnapshot
210
214
  };
211
215
  };
212
216
  }