@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
@@ -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() {
@@ -0,0 +1,271 @@
1
+ // Tests for the Aggregate_Callback in-process replay cache (Phase 1 of
2
+ // docs/plans/aggregate-snapshotting.md): a warm same-id command skips the
3
+ // event-log replay and decides on the cached (state, sequenceNr); the OCC
4
+ // append fences staleness — a stale cache conflicts, invalidates, and the
5
+ // retry replays cold.
6
+ //
7
+ // Unlike the other aggregate mocks, this one enforces optimistic concurrency
8
+ // (append conflicts unless seqNr matches the stored count), because the cache's
9
+ // correctness argument rests on exactly that fence. The Checkpoint command
10
+ // records the decision state's size as an event, making the cached fold state
11
+ // observable through ordinary domain behavior.
12
+
13
+ open JestGlobals
14
+
15
+ S.enableJson()
16
+
17
+ module AggSpec = {
18
+ module Id = Reventless.Id.StringPure
19
+ let name = "CacheTestAggregate"
20
+
21
+ @schema
22
+ type command =
23
+ | Add({name: string})
24
+ | Checkpoint
25
+ | Noop
26
+
27
+ @schema
28
+ type event =
29
+ | Added({name: string})
30
+ | CheckpointTaken({count: int})
31
+
32
+ @schema
33
+ type error = | Never
34
+
35
+ let moduleUrl: string = %raw(`import.meta.url`)
36
+ }
37
+
38
+ module TestBehavior = {
39
+ module Spec = AggSpec
40
+ type state = {names: array<string>}
41
+
42
+ let initialState = {names: []}
43
+
44
+ // Hand-written behavior (no @@reventless.behavior PPX) — satisfy the
45
+ // Behavior.T snapshot field manually.
46
+ let snapshot = None
47
+
48
+ let moduleUrl: string = %raw(`import.meta.url`)
49
+
50
+ let evolve = (state: state, event: AggSpec.event): state =>
51
+ switch event {
52
+ | Added({name}) => {names: state.names->Array.concat([name])}
53
+ | CheckpointTaken(_) => state
54
+ }
55
+
56
+ let decide = (state: state, command: AggSpec.command): result<
57
+ array<AggSpec.event>,
58
+ AggSpec.error,
59
+ > =>
60
+ switch command {
61
+ | Add({name}) => Ok([AggSpec.Added({name: name})])
62
+ | Checkpoint => Ok([AggSpec.CheckpointTaken({count: state.names->Array.length})])
63
+ | Noop => Ok([])
64
+ }
65
+ }
66
+
67
+ let testMeta: Message.meta = {
68
+ service: "CacheTestAggregate",
69
+ time: "2024-01-01T00:00:00Z",
70
+ ip: "127.0.0.1",
71
+ user: "test-user",
72
+ msgId: "msg-1",
73
+ correlationId: "corr-1",
74
+ }
75
+
76
+ type mockEL = {
77
+ appendFn: (
78
+ int,
79
+ string,
80
+ array<Message.event'<string, AggSpec.event>>,
81
+ ) => promise<result<unit, EventLog.appendError>>,
82
+ replayFn: string => promise<array<AggSpec.event>>,
83
+ replayStreamFn: string => Stream.t<AggSpec.event, string, unit>,
84
+ getAll: unit => array<Message.event'<string, AggSpec.event>>,
85
+ // Appends directly to storage, bypassing the handler — simulates a second
86
+ // writer the warm cache doesn't know about.
87
+ injectExternal: (string, AggSpec.event) => unit,
88
+ appendCallCount: ref<int>,
89
+ replayCallCount: ref<int>,
90
+ reset: unit => unit,
91
+ }
92
+
93
+ let makeMockEL = (): mockEL => {
94
+ let storedRef: ref<array<Message.event'<string, AggSpec.event>>> = ref([])
95
+ let appendCount = ref(0)
96
+ let replayCount = ref(0)
97
+
98
+ let eventsFor = id => storedRef.contents->Array.filter(e => e.id == id)
99
+
100
+ // Real OCC: the append succeeds only when seqNr matches the current count.
101
+ let appendFn = async (seqNr, id, newEvents) => {
102
+ appendCount := appendCount.contents + 1
103
+ if seqNr != eventsFor(id)->Array.length {
104
+ Error(EventLog.Conflict)
105
+ } else {
106
+ storedRef := storedRef.contents->Array.concat(newEvents)
107
+ Ok(())
108
+ }
109
+ }
110
+
111
+ let replayFn = async id => {
112
+ replayCount := replayCount.contents + 1
113
+ eventsFor(id)->Array.map(e => e.event)
114
+ }
115
+
116
+ let replayStreamFn = id => {
117
+ replayCount := replayCount.contents + 1
118
+ eventsFor(id)->Array.map(e => e.event)->Stream.fromIterable
119
+ }
120
+
121
+ {
122
+ appendFn,
123
+ replayFn,
124
+ replayStreamFn,
125
+ getAll: () => storedRef.contents,
126
+ injectExternal: (id, event) =>
127
+ storedRef := storedRef.contents->Array.concat([{Message.id, meta: testMeta, event}]),
128
+ appendCallCount: appendCount,
129
+ replayCallCount: replayCount,
130
+ reset: () => {
131
+ storedRef := []
132
+ appendCount := 0
133
+ replayCount := 0
134
+ },
135
+ }
136
+ }
137
+
138
+ let mock = makeMockEL()
139
+
140
+ module OuterEventLog = EventLog
141
+
142
+ module TestOps = {
143
+ module Spec = AggSpec
144
+ module EventLog = {
145
+ module Spec = {
146
+ module Id = AggSpec.Id
147
+ let name = "CacheMockEventLog"
148
+ @schema
149
+ type event = AggSpec.event
150
+ }
151
+ type operations = {
152
+ append: (
153
+ int,
154
+ string,
155
+ array<Message.event'<string, AggSpec.event>>,
156
+ ) => promise<result<unit, EventLog.appendError>>,
157
+ replay: string => promise<array<AggSpec.event>>,
158
+ replayStream: (string, ~fromSeq: int=?) => Stream.t<AggSpec.event, string, unit>,
159
+ appendStream: (int, string, Stream.t<AggSpec.event, string, unit>) => Effect.t<
160
+ unit,
161
+ string,
162
+ unit,
163
+ >,
164
+ latestSnapshot: string => promise<result<option<EventLog.snapshot>, string>>,
165
+ writeSnapshot: (string, EventLog.snapshot) => promise<result<unit, string>>,
166
+ }
167
+ type component = Component.t<OuterEventLog.t, OuterEventLog.outputs, operations>
168
+ // Never called — satisfies module type only
169
+ let make = (~name as _: string, ~opts as _=?): component => Obj.magic(0)
170
+ }
171
+ let eventLog: EventLog.operations = {
172
+ append: mock.appendFn,
173
+ replay: mock.replayFn,
174
+ replayStream: (id, ~fromSeq as _=?) => mock.replayStreamFn(id),
175
+ appendStream: (_startingSeqNr, _id, stream) => stream->Stream.runDrain,
176
+ latestSnapshot: async _ => Ok(None),
177
+ writeSnapshot: async (_, _) => Ok(),
178
+ }
179
+ }
180
+
181
+ module TestHandler = Aggregate_Callback.Make(AggSpec, TestBehavior, TestOps)
182
+
183
+ let makeTopicItem = (reference, command): CommandTopic.topicItem<
184
+ Message.command'<string, AggSpec.command>,
185
+ > => {
186
+ command: {
187
+ id: "agg-1",
188
+ meta: testMeta,
189
+ command,
190
+ },
191
+ reference,
192
+ }
193
+
194
+ let run = async command =>
195
+ await Stream.fromIterable([makeTopicItem("ref", command)])
196
+ ->TestHandler.handleCommands
197
+ ->Effect.runPromise
198
+
199
+ let _ = beforeEach(() => {
200
+ mock.reset()
201
+ TestHandler.resetCache()
202
+ })
203
+
204
+ describe("Aggregate_Callback — replay cache:", () => {
205
+ testPromise("warm same-id command skips the replay", async () => {
206
+ let r1 = await run(AggSpec.Add({name: "a"}))
207
+ let r2 = await run(AggSpec.Add({name: "b"}))
208
+ expect((r1, r2))->toEqual(([Ok("ref")], [Ok("ref")]))
209
+ // Only the first command replayed; the second decided on the cached state.
210
+ expect(mock.replayCallCount.contents)->toBe(1)
211
+ // Both appends landed at their correct OCC positions (no conflict retries).
212
+ expect((mock.appendCallCount.contents, mock.getAll()->Array.length))->toEqual((2, 2))
213
+ })
214
+
215
+ testPromise("cached fold state equals a cold replay of the log", async () => {
216
+ let _ = await run(AggSpec.Add({name: "a"}))
217
+ let _ = await run(AggSpec.Add({name: "b"}))
218
+ let _ = await run(AggSpec.Add({name: "c"}))
219
+ // Warm checkpoint: decided on the cached state (no replay since call 1).
220
+ let _ = await run(AggSpec.Checkpoint)
221
+ expect(mock.replayCallCount.contents)->toBe(1)
222
+ // Cold checkpoint: same aggregate replayed from the log.
223
+ TestHandler.resetCache()
224
+ let _ = await run(AggSpec.Checkpoint)
225
+ expect(mock.replayCallCount.contents)->toBe(2)
226
+ // Both checkpoints observed the same state — cached fold ≡ replayed fold.
227
+ let checkpoints =
228
+ mock.getAll()
229
+ ->Array.filterMap(e =>
230
+ switch e.event {
231
+ | CheckpointTaken({count}) => Some(count)
232
+ | _ => None
233
+ }
234
+ )
235
+ expect(checkpoints)->toEqual([3, 3])
236
+ })
237
+
238
+ testPromise("stale cache conflicts, invalidates, and self-heals", async () => {
239
+ let _ = await run(AggSpec.Add({name: "a"}))
240
+ // A second writer appends behind the warm cache's back.
241
+ mock.injectExternal("agg-1", AggSpec.Added({name: "external"}))
242
+ // Warm path appends at the stale seq → OCC conflict → invalidate →
243
+ // cold retry reads the authoritative log and succeeds.
244
+ let results = await run(AggSpec.Add({name: "b"}))
245
+ expect(results)->toEqual([Ok("ref")])
246
+ // Replays: initial cold + post-conflict cold (the warm attempt skipped).
247
+ expect(mock.replayCallCount.contents)->toBe(2)
248
+ expect(mock.getAll()->Array.length)->toBe(3)
249
+ // The refreshed state includes the external event.
250
+ let _ = await run(AggSpec.Checkpoint)
251
+ let last = mock.getAll()->Array.getUnsafe(3)
252
+ expect(last.event)->toEqual(AggSpec.CheckpointTaken({count: 3}))
253
+ })
254
+
255
+ testPromise("Ok([]) keeps the read snapshot warm", async () => {
256
+ let _ = await run(AggSpec.Add({name: "a"}))
257
+ let _ = await run(AggSpec.Noop)
258
+ let _ = await run(AggSpec.Add({name: "b"}))
259
+ // Neither the Noop nor the third command replayed.
260
+ expect(mock.replayCallCount.contents)->toBe(1)
261
+ expect(mock.getAll()->Array.length)->toBe(2)
262
+ })
263
+
264
+ testPromise("resetCache forces the next command to replay cold", async () => {
265
+ let _ = await run(AggSpec.Add({name: "a"}))
266
+ TestHandler.resetCache()
267
+ let _ = await run(AggSpec.Add({name: "b"}))
268
+ expect(mock.replayCallCount.contents)->toBe(2)
269
+ expect(mock.getAll()->Array.length)->toBe(2)
270
+ })
271
+ })