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

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 (39) hide show
  1. package/CHANGELOG.md +12 -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 +61 -6
  8. package/src/components/Aggregate/Aggregate_Callback.res.mjs +37 -9
  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/eventlog/EventLogFixtures.res +13 -2
  37. package/tests/eventlog/EventLogFixtures.res.mjs +27 -3
  38. package/tests/logger/LoggerLazyTest.res +36 -0
  39. 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,28 @@ 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
+
98
125
  // Reports per-command outcomes on the inline side-channels and produces the
99
126
  // per-reference Ok/Error array consumed by SQS-style consumers. Domain rejections
100
127
  // are always Ok(reference) — SQS deletes the message because retry would not help —
@@ -133,13 +160,25 @@ module Make = (
133
160
  ) => {
134
161
  let idStr = id->Spec.Id.toString
135
162
 
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
- )
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.
165
+ let readState = switch replayCache->Lru.get(idStr) {
166
+ | Some((state, seqNr)) =>
167
+ EffectLogger.logInfo(
168
+ ~comp,
169
+ `replay skipped (cached): id=${idStr}, seq=${seqNr->Int.toString}`,
170
+ )->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
+ )
177
+ }
178
+
179
+ readState
141
180
  ->Effect.flatMap(((initialState, sequenceNr)) => {
142
- let (_finalState, outcomes) =
181
+ let (finalState, outcomes) =
143
182
  topicItemsForId->Array.reduce((initialState, []), processCommand)
144
183
 
145
184
  let eventsToAppend =
@@ -154,6 +193,9 @@ module Make = (
154
193
 
155
194
  switch eventsToAppend {
156
195
  | [] =>
196
+ // Nothing appended, but the read snapshot is valid — keep it warm for
197
+ // the next command (also refreshes recency on a cache hit).
198
+ replayCache->Lru.put(idStr, (initialState, sequenceNr))
157
199
  let perRef = reportFinalOutcomes(
158
200
  outcomes,
159
201
  ~entityId=idStr,
@@ -178,6 +220,12 @@ module Make = (
178
220
  appendResult =>
179
221
  switch appendResult {
180
222
  | Ok(_) =>
223
+ // The post-decide fold state IS the post-append replay result;
224
+ // 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
+ )
181
229
  let perRef = reportFinalOutcomes(
182
230
  outcomes,
183
231
  ~entityId=idStr,
@@ -186,6 +234,9 @@ module Make = (
186
234
  )
187
235
  EffectLogger.logInfo(~comp, `append: id=${idStr}`)->Effect.map(_ => Ok(perRef))
188
236
  | Error(EventLog.Conflict) =>
237
+ // Another writer advanced the stream past our (possibly cached)
238
+ // sequenceNr — drop the entry so the retry replays cold.
239
+ replayCache->Lru.invalidate(idStr)
189
240
  // Signal the outer replay+re-decide retry loop (which matches
190
241
  // Error(_)); the string is an internal marker, no longer a
191
242
  // cross-component substring sentinel.
@@ -193,6 +244,10 @@ module Make = (
193
244
  _ => Error("conflict"),
194
245
  )
195
246
  | Error(EventLog.StorageFailure(msg)) =>
247
+ // Appends are atomic, so the read snapshot is likely still valid —
248
+ // but a storage error means we can't be sure what committed;
249
+ // drop the entry so the next attempt reads authoritative state.
250
+ replayCache->Lru.invalidate(idStr)
196
251
  let perRef = reportFinalOutcomes(
197
252
  outcomes,
198
253
  ~entityId=idStr,
@@ -4,6 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Effect from "effect/Effect";
6
6
  import * as Stream from "effect/Stream";
7
+ import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
7
8
  import * as Message$ReventlessCore from "../../Message.res.mjs";
8
9
  import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
9
10
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
@@ -64,6 +65,8 @@ function Make(Spec) {
64
65
  ]])
65
66
  ];
66
67
  };
68
+ let replayCache = Lru$ReventlessCore.make(100);
69
+ let resetCache = () => Lru$ReventlessCore.clear(replayCache);
67
70
  let reportFinalOutcomes = (outcomes, entityId, appendSucceeded, appendedEventCount, appendErrorDetailOpt) => {
68
71
  let appendErrorDetail = appendErrorDetailOpt !== undefined ? appendErrorDetailOpt : "";
69
72
  return outcomes.map(param => {
@@ -102,19 +105,33 @@ function Make(Spec) {
102
105
  };
103
106
  let replayProcessAppend = (id, topicItemsForId) => {
104
107
  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 => {
108
+ let match = Lru$ReventlessCore.get(replayCache, idStr);
109
+ let readState;
110
+ if (match !== undefined) {
111
+ let seqNr = match[1];
112
+ let state = match[0];
113
+ readState = Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay skipped (cached): id=` + idStr + `, seq=` + seqNr.toString()), () => [
114
+ state,
115
+ seqNr
116
+ ]);
117
+ } 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)`));
125
+ }
126
+ return Effect.flatMap(readState, param => {
112
127
  let sequenceNr = param[1];
128
+ let initialState = param[0];
113
129
  let match = Stdlib_Array.reduce(topicItemsForId, [
114
- param[0],
130
+ initialState,
115
131
  []
116
132
  ], processCommand);
117
133
  let outcomes = match[1];
134
+ let finalState = match[0];
118
135
  let eventsToAppend = outcomes.map(param => {
119
136
  let meta = param[2];
120
137
  let outcome = param[1];
@@ -139,6 +156,10 @@ function Make(Spec) {
139
156
  let eventJsons = eventsToAppend.map(event$p => Message$ReventlessCore.encode(event$p.event, Spec.eventSchema));
140
157
  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
158
  if (appendResult.TAG === "Ok") {
159
+ Lru$ReventlessCore.put(replayCache, idStr, [
160
+ finalState,
161
+ sequenceNr + eventsToAppend.length | 0
162
+ ]);
142
163
  let perRef = reportFinalOutcomes(outcomes, idStr, true, eventsToAppend.length, undefined);
143
164
  return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `append: id=` + idStr), () => ({
144
165
  TAG: "Ok",
@@ -147,12 +168,14 @@ function Make(Spec) {
147
168
  }
148
169
  let msg = appendResult._0;
149
170
  if (typeof msg !== "object") {
171
+ Lru$ReventlessCore.invalidate(replayCache, idStr);
150
172
  return Effect.map(EffectLogger$ReventlessCore.logWarn(comp, undefined, `conflict: id=` + idStr + `, will retry`), () => ({
151
173
  TAG: "Error",
152
174
  _0: "conflict"
153
175
  }));
154
176
  }
155
177
  let msg$1 = msg._0;
178
+ Lru$ReventlessCore.invalidate(replayCache, idStr);
156
179
  let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg$1);
157
180
  return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg$1), () => ({
158
181
  TAG: "Ok",
@@ -160,6 +183,10 @@ function Make(Spec) {
160
183
  }));
161
184
  });
162
185
  }
186
+ Lru$ReventlessCore.put(replayCache, idStr, [
187
+ initialState,
188
+ sequenceNr
189
+ ]);
163
190
  let perRef = reportFinalOutcomes(outcomes, idStr, true, 0, undefined);
164
191
  return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `no events produced: id=` + idStr), () => ({
165
192
  TAG: "Ok",
@@ -198,7 +225,8 @@ function Make(Spec) {
198
225
  }), prim => prim.flat()));
199
226
  return {
200
227
  Spec: Spec,
201
- handleCommands: handleCommands
228
+ handleCommands: handleCommands,
229
+ resetCache: resetCache
202
230
  };
203
231
  });
204
232
  }
@@ -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
  }
@@ -4,6 +4,13 @@ type publishedEvent = {
4
4
  eventCount: int,
5
5
  eventsJson: array<JSON.t>,
6
6
  meta: Reventless.Message.meta,
7
+ // Per-event metas, parallel to eventsJson. Populated by publishers whose
8
+ // eventsJson entries do NOT carry the meta fields inline (DcbEventLog: bare
9
+ // {TAG, ...data} payloads) so hook consumers can still correlate per event
10
+ // (e.g. the local projection checkpoint resolves pending appends by msgId).
11
+ // Aggregate EventLog eventsJson are flat stored events with meta at the top
12
+ // level, so it omits this field.
13
+ metas?: array<Reventless.Message.meta>,
7
14
  }
8
15
 
9
16
  type beforePublishHook = publishedEvent => promise<publishedEvent>
@@ -199,13 +199,33 @@ module Make = (
199
199
  Some(Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec))
200
200
  }
201
201
 
202
- let cacheKey = query->JSON.stringifyAny->Option.getOr("")
202
+ // Fail closed: a query that can't be serialized yields `None`, not the empty
203
+ // string. The old `""` fallback made every unserializable query collide on
204
+ // one shared cache slot, so one slice's folded state could seed another's —
205
+ // a silent correctness bug. `None` simply bypasses the cache (cold read, no
206
+ // write) for that operation.
207
+ let cacheKey = query->JSON.stringifyAny
208
+ let cacheGet = () =>
209
+ switch cacheKey {
210
+ | Some(k) => Lru.get(projectionCache, k)
211
+ | None => None
212
+ }
213
+ let cachePut = v =>
214
+ switch cacheKey {
215
+ | Some(k) => Lru.put(projectionCache, k, v)
216
+ | None => ()
217
+ }
218
+ let cacheInvalidate = () =>
219
+ switch cacheKey {
220
+ | Some(k) => Lru.invalidate(projectionCache, k)
221
+ | None => ()
222
+ }
203
223
 
204
224
  // Working seed across retries: the `(state, head)` the read folds from.
205
225
  // Starts from the projection cache (warm) or empty (cold); the conflict
206
226
  // branch re-seeds it with the just-read `(state, head)` so each retry reads
207
227
  // only the delta the conflicting writer added rather than full history.
208
- let seed = ref(Lru.get(projectionCache, cacheKey))
228
+ let seed = ref(cacheGet())
209
229
 
210
230
  let rec attempt = (~retries) => {
211
231
  let (baseState, afterPos) = switch seed.contents {
@@ -275,7 +295,7 @@ module Make = (
275
295
  switch Behavior.decide(state, command'.command) {
276
296
  | Ok(newEvents) if newEvents->Array.length == 0 =>
277
297
  // No append, but the read snapshot is valid — cache it for the next command.
278
- Lru.put(projectionCache, cacheKey, (state, headPosition))
298
+ cachePut((state, headPosition))
279
299
  CommandTopic_Helpers.reportAccepted(
280
300
  cmdJson.meta.msgId,
281
301
  switch entityId {
@@ -326,7 +346,7 @@ module Make = (
326
346
  // Cache the decided-on state at the read head (NOT including the
327
347
  // events we just appended, and NOT the returned position): the
328
348
  // next command's delta read picks our events up from `headPosition`.
329
- Lru.put(projectionCache, cacheKey, (state, headPosition))
349
+ cachePut((state, headPosition))
330
350
  CommandTopic_Helpers.reportAccepted(
331
351
  cmdJson.meta.msgId,
332
352
  switch entityId {
@@ -354,7 +374,7 @@ module Make = (
354
374
  } else {
355
375
  // Retries exhausted — drop any cached snapshot so the next
356
376
  // command for this entity takes the cold full-read path.
357
- Lru.invalidate(projectionCache, cacheKey)
377
+ cacheInvalidate()
358
378
  let errorCode = err->String.startsWith("Conflict") ? "Conflict" : "AppendFailed"
359
379
  // A surfaced Conflict means the 3-retry loop could not absorb
360
380
  // the contention — the operator signal for "this slice is hot"
@@ -375,7 +395,7 @@ module Make = (
375
395
  )
376
396
  | Error(error) =>
377
397
  // Business-rule rejection — no append, but the read snapshot is valid; cache it.
378
- Lru.put(projectionCache, cacheKey, (state, headPosition))
398
+ cachePut((state, headPosition))
379
399
  let errorJson = error->S.reverseConvertToJsonOrThrow(Spec.errorSchema)
380
400
  let errorCode = errorJson->Message.variantNameOfJson
381
401
  let (_, payloadDict) = errorJson->Message.splitMessage
@@ -82,9 +82,24 @@ function Make(Spec) {
82
82
  let tags = DcbTag$Reventless.extractTags(Spec.commandSchema, command$p.command);
83
83
  entityId = DcbTag$Reventless.getCompositePartitionKeyValue(tags, derivedPartitionTag._0);
84
84
  }
85
- let cacheKey = Stdlib_Option.getOr(JSON.stringify(query), "");
85
+ let cacheKey = JSON.stringify(query);
86
+ let cacheGet = () => {
87
+ if (cacheKey !== undefined) {
88
+ return Lru$ReventlessCore.get(projectionCache, cacheKey);
89
+ }
90
+ };
91
+ let cachePut = v => {
92
+ if (cacheKey !== undefined) {
93
+ return Lru$ReventlessCore.put(projectionCache, cacheKey, v);
94
+ }
95
+ };
96
+ let cacheInvalidate = () => {
97
+ if (cacheKey !== undefined) {
98
+ return Lru$ReventlessCore.invalidate(projectionCache, cacheKey);
99
+ }
100
+ };
86
101
  let seed = {
87
- contents: Lru$ReventlessCore.get(projectionCache, cacheKey)
102
+ contents: cacheGet()
88
103
  };
89
104
  let attempt = retries => {
90
105
  let match = seed.contents;
@@ -150,7 +165,7 @@ function Make(Spec) {
150
165
  if (newEvents.TAG === "Ok") {
151
166
  let newEvents$1 = newEvents._0;
152
167
  if (newEvents$1.length === 0) {
153
- Lru$ReventlessCore.put(projectionCache, cacheKey, [
168
+ cachePut([
154
169
  state,
155
170
  headPosition
156
171
  ]);
@@ -204,7 +219,7 @@ function Make(Spec) {
204
219
  };
205
220
  return Effect.flatMap(Effect.flatMap(EffectLogger$ReventlessCore.logInfo(comp, eventJsons, `produced ` + eventCount + ` event(s): [` + eventDetails + `]`), () => Effect.promise(() => dcbEventLog.append(rawEvents, condition))), appendResult => {
206
221
  if (appendResult.TAG === "Ok") {
207
- Lru$ReventlessCore.put(projectionCache, cacheKey, [
222
+ cachePut([
208
223
  state,
209
224
  headPosition
210
225
  ]);
@@ -228,7 +243,7 @@ function Make(Spec) {
228
243
  Metrics$ReventlessCore.emitCount("AppendRetry", Spec.name, undefined);
229
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));
230
245
  }
231
- Lru$ReventlessCore.invalidate(projectionCache, cacheKey);
246
+ cacheInvalidate();
232
247
  let errorCode = err.startsWith("Conflict") ? "Conflict" : "AppendFailed";
233
248
  if (errorCode === "Conflict") {
234
249
  Metrics$ReventlessCore.emitCount("AppendConflict", Spec.name, undefined);
@@ -243,7 +258,7 @@ function Make(Spec) {
243
258
  }));
244
259
  });
245
260
  }
246
- Lru$ReventlessCore.put(projectionCache, cacheKey, [
261
+ cachePut([
247
262
  state,
248
263
  headPosition
249
264
  ]);
@@ -30,6 +30,12 @@ type state = {
30
30
 
31
31
  let initialState = {current: None, known: Dict.make()}
32
32
 
33
+ // The @@reventless.behavior snapshot injection is gated to Aggregate/ folders;
34
+ // this framework-internal behavior lives under plugin/lifecycle, so the
35
+ // Behavior.T field is satisfied manually. The admin Plugin aggregate's history
36
+ // is short (lifecycle transitions), so persisted snapshots would buy nothing.
37
+ let snapshot = None
38
+
33
39
  let atomicCounter = None
34
40
 
35
41
  let uiRegisterEvents = (pluginId, manifest) =>
@@ -366,6 +366,8 @@ function evolve(state, event) {
366
366
 
367
367
  let Spec;
368
368
 
369
+ let snapshot;
370
+
369
371
  let atomicCounter;
370
372
 
371
373
  let moduleUrl = "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginBehavior.res.mjs";
@@ -376,6 +378,7 @@ export {
376
378
  knownVersionSchema,
377
379
  stateSchema,
378
380
  initialState,
381
+ snapshot,
379
382
  atomicCounter,
380
383
  uiRegisterEvents,
381
384
  uiDeregisterEvents,
@@ -37,6 +37,11 @@ type t = {
37
37
  info: logFn,
38
38
  warn: logFn,
39
39
  error: logFn,
40
+ // Lazy debug: the message thunk runs only when debug output is actually
41
+ // enabled. On a hot path (e.g. per-projection-action logging) this avoids
42
+ // building — and serializing state into — a string that is then dropped at
43
+ // the default Info level.
44
+ debugLazy: (~comp: string=?, unit => string) => unit,
40
45
  }
41
46
 
42
47
  type level = Debug | Info | Warn | Error
@@ -172,6 +177,10 @@ let makeLogger = (~minLevel=Info): t => {
172
177
  info: (~comp=?, ~data=?, msg) => log(Info, ~comp?, ~data?, msg),
173
178
  warn: (~comp=?, ~data=?, msg) => log(Warn, ~comp?, ~data?, msg),
174
179
  error: (~comp=?, ~data=?, msg) => log(Error, ~comp?, ~data?, msg),
180
+ debugLazy: (~comp=?, makeMsg) =>
181
+ if shouldLog(Debug) {
182
+ emit(~level=Debug, ~comp?, makeMsg())
183
+ },
175
184
  }
176
185
  }
177
186
 
@@ -180,6 +189,7 @@ let silent: t = {
180
189
  info: (~comp as _=?, ~data as _=?, _) => (),
181
190
  warn: (~comp as _=?, ~data as _=?, _) => (),
182
191
  error: (~comp as _=?, ~data as _=?, _) => (),
192
+ debugLazy: (~comp as _=?, _) => (),
183
193
  }
184
194
 
185
195
  // Reads LOG_LEVEL from process.env and returns a configured logger.
@@ -156,7 +156,12 @@ function makeLogger(minLevelOpt) {
156
156
  debug: (comp, data, msg) => log("Debug", comp, data, msg),
157
157
  info: (comp, data, msg) => log("Info", comp, data, msg),
158
158
  warn: (comp, data, msg) => log("Warn", comp, data, msg),
159
- error: (comp, data, msg) => log("Error", comp, data, msg)
159
+ error: (comp, data, msg) => log("Error", comp, data, msg),
160
+ debugLazy: (comp, makeMsg) => {
161
+ if (shouldLog("Debug")) {
162
+ return emit("Debug", comp, undefined, undefined, undefined, makeMsg());
163
+ }
164
+ }
160
165
  };
161
166
  }
162
167
 
@@ -176,11 +181,16 @@ function silent_error(param, param$1, param$2) {
176
181
 
177
182
  }
178
183
 
184
+ function silent_debugLazy(param, param$1) {
185
+
186
+ }
187
+
179
188
  let silent = {
180
189
  debug: silent_debug,
181
190
  info: silent_info,
182
191
  warn: silent_warn,
183
- error: silent_error
192
+ error: silent_error,
193
+ debugLazy: silent_debugLazy
184
194
  };
185
195
 
186
196
  function fromEnv() {