@reventlessdev/reventless-core 3.0.0-alpha.135 → 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 (51) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +8 -8
  3. package/src/Message.res +43 -10
  4. package/src/Message.res.mjs +21 -4
  5. package/src/Projection.res +83 -55
  6. package/src/Projection.res.mjs +69 -57
  7. package/src/components/Aggregate/Aggregate_Callback.res +67 -9
  8. package/src/components/Aggregate/Aggregate_Callback.res.mjs +42 -13
  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 +33 -2
  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 +48 -36
  16. package/src/components/EventLog/EventLog_Operations.res.mjs +36 -15
  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/component/SchemaWalker.res +13 -1
  21. package/src/plugin/component/SchemaWalker.res.mjs +5 -4
  22. package/src/plugin/lifecycle/PluginBehavior.res +6 -0
  23. package/src/plugin/lifecycle/PluginBehavior.res.mjs +3 -0
  24. package/src/util/CommandPublisher.res +20 -10
  25. package/src/util/CommandPublisher.res.mjs +6 -10
  26. package/src/util/Logger.res +10 -0
  27. package/src/util/Logger.res.mjs +12 -2
  28. package/src/util/Util_Array.res +1 -1
  29. package/src/util/Util_Array.res.mjs +1 -1
  30. package/tests/aggregate/AggregateCacheTest.res +271 -0
  31. package/tests/aggregate/AggregateCacheTest.res.mjs +433 -0
  32. package/tests/aggregate/AggregateCallbackTest.res +4 -1
  33. package/tests/aggregate/AggregateCallbackTest.res.mjs +4 -1
  34. package/tests/aggregate/AggregateCausationTest.res +4 -1
  35. package/tests/aggregate/AggregateCausationTest.res.mjs +4 -1
  36. package/tests/aggregate/AggregateConflictTest.res +17 -6
  37. package/tests/aggregate/AggregateConflictTest.res.mjs +27 -4
  38. package/tests/aggregate/AggregateFixtures.res +13 -5
  39. package/tests/aggregate/AggregateFixtures.res.mjs +26 -3
  40. package/tests/aggregate/AggregateRejectionTest.res +4 -1
  41. package/tests/aggregate/AggregateRejectionTest.res.mjs +4 -1
  42. package/tests/eventlog/EventLogFixtures.res +15 -4
  43. package/tests/eventlog/EventLogFixtures.res.mjs +35 -5
  44. package/tests/eventlog/EventLogRetryTest.res +12 -11
  45. package/tests/eventlog/EventLogRetryTest.res.mjs +31 -7
  46. package/tests/logger/LoggerLazyTest.res +36 -0
  47. package/tests/logger/LoggerLazyTest.res.mjs +40 -0
  48. package/tests/projection/ProjectionOptimizeTest.res +38 -0
  49. package/tests/projection/ProjectionOptimizeTest.res.mjs +75 -0
  50. package/tests/util/CommandPublisherTest.res +56 -0
  51. package/tests/util/CommandPublisherTest.res.mjs +78 -0
@@ -11,19 +11,28 @@ 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.
17
19
  // Exponential backoff: 100ms, ~200ms, ~400ms, ~800ms, ~1600ms — max 5 retries.
18
20
  // Only retries on recognised transient error messages; permanent errors propagate immediately.
19
- let isTransient = (msg: string) =>
20
- msg->String.includes("ThrottlingException") ||
21
- msg->String.includes("ProvisionedThroughputExceededException") ||
22
- msg->String.includes("ServiceUnavailable") ||
23
- msg->String.includes("RequestLimitExceeded") ||
24
- msg->String.includes("InternalServerError")
25
-
26
- let storageRetrySchedule: Schedule.t<(Duration.t, int), string, unit> =
21
+ // Retry only recognised transient storage failures — never a Conflict (a retry
22
+ // of the same append would just conflict again; the OCC retry happens a layer up
23
+ // via replay+re-decide).
24
+ let isTransient = (e: EventLog.appendError) =>
25
+ switch e {
26
+ | EventLog.Conflict => false
27
+ | StorageFailure(msg) =>
28
+ msg->String.includes("ThrottlingException") ||
29
+ msg->String.includes("ProvisionedThroughputExceededException") ||
30
+ msg->String.includes("ServiceUnavailable") ||
31
+ msg->String.includes("RequestLimitExceeded") ||
32
+ msg->String.includes("InternalServerError")
33
+ }
34
+
35
+ let storageRetrySchedule: Schedule.t<(Duration.t, int), EventLog.appendError, unit> =
27
36
  Schedule.exponential(Duration.millis(100))
28
37
  ->Schedule.jittered
29
38
  ->Schedule.intersect(Schedule.recurs(5))
@@ -112,44 +121,40 @@ module Make = (Spec: ReventlessInfra.EventLog.T, Ops: Ops with module Spec = Spe
112
121
  }
113
122
  }
114
123
 
115
- // append returns result<unit, string> — never throws.
116
- // Storage errors are retried with exponential backoff (up to 5 times for transient errors).
117
- // After exhausting retries, or on permanent errors, returns Error.
118
- let isConflict = (msg: string) => msg->String.includes("conflict")
119
-
124
+ // append returns result<unit, EventLog.appendError> — never throws.
125
+ // Transient storage failures are retried with exponential backoff (up to 5
126
+ // times); a Conflict is returned immediately (retried a layer up), and other
127
+ // failures propagate after retries are exhausted.
120
128
  let append = async (sequenceNr, id, events') => {
121
129
  let eventsJson = events'->encodeEvents'(id, sequenceNr)
122
130
  let idStr = id->Spec.Id.toString
123
- // Build an Effect that fails with a string on storage error (enabling retry)
131
+ // A thrown storage error becomes a StorageFailure; a returned Error keeps its
132
+ // typed variant. The Effect fails with the typed appendError so the schedule
133
+ // can retry only transient failures, then we collapse the outcome back into a
134
+ // result (no Cause stringification / substring matching).
124
135
  let storageEffect =
125
136
  Effect.tryPromise(
126
- ~catch=(err: unknown) => Util.Error.messageFromUnknown(err, "storage error"),
137
+ ~catch=(err: unknown) =>
138
+ EventLog.StorageFailure(Util.Error.messageFromUnknown(err, "storage error")),
127
139
  () => Ops.storage.append(sequenceNr, idStr, eventsJson),
128
140
  )
129
141
  ->Effect.flatMap(result =>
130
142
  switch result {
131
- | Ok(_) => Effect.succeed(())
132
- | Error(msg) => Effect.fail(msg)
143
+ | Ok(_) => Effect.succeed()
144
+ | Error(e) => Effect.fail(e)
133
145
  }
134
146
  )
135
147
  ->Effect.retry(storageRetrySchedule)
136
- let exit = await storageEffect->Effect.runPromiseExit
137
- if exit->Exit.isSuccess {
138
- await publishToEventTopic(id, events', eventsJson)
139
- } else {
140
- // Retry exhausted — extract the final error message from the Cause.
141
- // Use Cause.pretty so defects (uncaught throws) are surfaced too, not
142
- // just typed failures from Cause.failures.
143
- let failMsg = exit->Exit.match(
144
- ~onFailure=cause => cause->Cause.pretty,
145
- ~onSuccess=_ => "storage error", // unreachable: we are in the isFailure branch
146
- )
147
- // Propagate conflict errors without wrapping so callers can detect them
148
- if isConflict(failMsg) {
149
- Error("conflict")
150
- } else {
151
- Error(`EventLog: Error: Couldn't append for ${Spec.name}(${idStr}): ${failMsg}`)
152
- }
148
+ ->Effect.map(_ => Ok())
149
+ ->Effect.catchAll(e => Effect.succeed(Error(e)))
150
+ switch await storageEffect->Effect.runPromise {
151
+ | Ok() =>
152
+ (await publishToEventTopic(id, events', eventsJson))->Result.mapError(msg => EventLog.StorageFailure(
153
+ msg,
154
+ ))
155
+ | Error(EventLog.Conflict) => Error(EventLog.Conflict)
156
+ | Error(StorageFailure(msg)) =>
157
+ Error(EventLog.StorageFailure(`EventLog: Error: Couldn't append for ${Spec.name}(${idStr}): ${msg}`))
153
158
  }
154
159
  }
155
160
 
@@ -184,12 +189,19 @@ module Make = (Spec: ReventlessInfra.EventLog.T, Ops: Ops with module Spec = Spe
184
189
 
185
190
  // Lazy streaming replay — wraps decodeEvent in Effect.sync so thrown exceptions
186
191
  // surface through the stream's error channel rather than as unhandled exceptions.
187
- let replayStream = id =>
188
- Ops.storage.replayStream(id->Spec.Id.toString)
192
+ let replayStream = (id, ~fromSeq=?) =>
193
+ Ops.storage.replayStream(id->Spec.Id.toString, ~fromSeq=?fromSeq)
189
194
  ->Stream.mapEffect(json =>
190
195
  Effect.sync(() => decodeEvent(id->Spec.Id.toString, json))
191
196
  )
192
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
+
193
205
  // Streaming append — encodes each Spec.event to the {type, data} storage format
194
206
  // and writes sequentially via the storage adapter.
195
207
  // Accepts Spec.event items (symmetric with replayStream) to enable direct
@@ -1,12 +1,10 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as Exit from "@reventlessdev/rescript-effect/src/Exit.res.mjs";
4
3
  import * as Effect from "@reventlessdev/rescript-effect/src/Effect.res.mjs";
5
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
- import * as Exit$1 from "effect/Exit";
7
5
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
8
- import * as Cause from "effect/Cause";
9
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
10
8
  import * as Effect$1 from "effect/Effect";
11
9
  import * as Stream from "effect/Stream";
12
10
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
@@ -18,7 +16,11 @@ import * as Util_Error$ReventlessCore from "../../util/Util_Error.res.mjs";
18
16
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
19
17
  import * as EventPublish_Callback$ReventlessCore from "./EventPublish_Callback.res.mjs";
20
18
 
21
- function isTransient(msg) {
19
+ function isTransient(e) {
20
+ if (typeof e !== "object") {
21
+ return false;
22
+ }
23
+ let msg = e._0;
22
24
  if (msg.includes("ThrottlingException") || msg.includes("ProvisionedThroughputExceededException") || msg.includes("ServiceUnavailable") || msg.includes("RequestLimitExceeded")) {
23
25
  return true;
24
26
  } else {
@@ -111,27 +113,42 @@ function Make(Spec) {
111
113
  let append = async (sequenceNr, id, events$p) => {
112
114
  let eventsJson = encodeEvents$p(events$p, id, sequenceNr);
113
115
  let idStr = Spec.Id.toString(id);
114
- let storageEffect = Effect$1.retry(Effect$1.flatMap(Effect.tryPromise(err => Util_Error$ReventlessCore.messageFromUnknown(err, "storage error"), () => Ops.storage.append(sequenceNr, idStr, eventsJson)), result => {
116
+ let storageEffect = Effect$1.catchAll(Effect$1.map(Effect$1.retry(Effect$1.flatMap(Effect.tryPromise(err => ({
117
+ TAG: "StorageFailure",
118
+ _0: Util_Error$ReventlessCore.messageFromUnknown(err, "storage error")
119
+ }), () => Ops.storage.append(sequenceNr, idStr, eventsJson)), result => {
115
120
  if (result.TAG === "Ok") {
116
121
  return Effect$1.succeed();
117
122
  } else {
118
123
  return Effect$1.fail(result._0);
119
124
  }
120
- }), storageRetrySchedule);
121
- let exit = await Effect$1.runPromiseExit(storageEffect);
122
- if (Exit$1.isSuccess(exit)) {
123
- return await publishToEventTopic(id, events$p, eventsJson);
125
+ }), storageRetrySchedule), () => ({
126
+ TAG: "Ok",
127
+ _0: undefined
128
+ })), e => Effect$1.succeed({
129
+ TAG: "Error",
130
+ _0: e
131
+ }));
132
+ let match = await Effect$1.runPromise(storageEffect);
133
+ if (match.TAG === "Ok") {
134
+ return Stdlib_Result.mapError(await publishToEventTopic(id, events$p, eventsJson), msg => ({
135
+ TAG: "StorageFailure",
136
+ _0: msg
137
+ }));
124
138
  }
125
- let failMsg = Exit.match(exit, cause => Cause.pretty(cause), () => "storage error");
126
- if (failMsg.includes("conflict")) {
139
+ let msg = match._0;
140
+ if (typeof msg !== "object") {
127
141
  return {
128
142
  TAG: "Error",
129
- _0: "conflict"
143
+ _0: "Conflict"
130
144
  };
131
145
  } else {
132
146
  return {
133
147
  TAG: "Error",
134
- _0: `EventLog: Error: Couldn't append for ` + Spec.name + `(` + idStr + `): ` + failMsg
148
+ _0: {
149
+ TAG: "StorageFailure",
150
+ _0: `EventLog: Error: Couldn't append for ` + Spec.name + `(` + idStr + `): ` + msg._0
151
+ }
135
152
  };
136
153
  }
137
154
  };
@@ -169,7 +186,9 @@ function Make(Spec) {
169
186
  let id$1 = Spec.Id.toString(id);
170
187
  return eventsJson.map(json => decodeEvent(id$1, json));
171
188
  };
172
- 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);
173
192
  let appendStream = (startingSeqNr, id, stream) => Ops.storage.appendStream(startingSeqNr, Spec.Id.toString(id), Stream.map(stream, event => {
174
193
  let json = Message$ReventlessCore.encode(event, Spec.eventSchema);
175
194
  let match = Message$ReventlessCore.splitMessage(json);
@@ -189,7 +208,9 @@ function Make(Spec) {
189
208
  append: append,
190
209
  replay: replay,
191
210
  replayStream: replayStream,
192
- appendStream: appendStream
211
+ appendStream: appendStream,
212
+ latestSnapshot: latestSnapshot,
213
+ writeSnapshot: writeSnapshot
193
214
  };
194
215
  };
195
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
  ]);
@@ -38,7 +38,19 @@ let rec describeSchema = (schema: S.t<unknown>): string =>
38
38
  | _ => "unknown"
39
39
  }
40
40
  "array<" ++ itemDesc ++ ">"
41
- | Object(_) => "object"
41
+ | Object({properties}) =>
42
+ // Recurse into nested records. Rendering every object as the literal
43
+ // "object" made the structural hash blind to nested field changes — a
44
+ // nested schema edit produced an identical SHA256, so drift detection
45
+ // silently missed it. Emit a sorted `{name:type,…}` description instead.
46
+ let inner =
47
+ properties
48
+ ->Dict.toArray
49
+ ->Array.filter(((name, _)) => name != "TAG")
50
+ ->Array.toSorted(((a, _), (b, _)) => String.compare(a, b))
51
+ ->Array.map(((name, s)) => name ++ ":" ++ describeSchema(s))
52
+ ->Array.join(",")
53
+ "{" ++ inner ++ "}"
42
54
  | _ => "unknown"
43
55
  }
44
56
 
@@ -23,7 +23,8 @@ function describeSchema(schema) {
23
23
  itemDesc = additionalItems === "strip" || additionalItems === "strict" ? "unknown" : describeSchema(additionalItems);
24
24
  return "array<" + itemDesc + ">";
25
25
  case "object" :
26
- return "object";
26
+ let inner = Object.entries(schema.properties).filter(param => param[0] !== "TAG").toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0])).map(param => param[0] + ":" + describeSchema(param[1])).join(",");
27
+ return "{" + inner + "}";
27
28
  case "union" :
28
29
  let nonNull = schema.anyOf.filter(v => {
29
30
  switch (v.type) {
@@ -34,9 +35,9 @@ function describeSchema(schema) {
34
35
  return true;
35
36
  }
36
37
  });
37
- let inner = nonNull[0];
38
- if (inner !== undefined) {
39
- return "option<" + describeSchema(inner) + ">";
38
+ let inner$1 = nonNull[0];
39
+ if (inner$1 !== undefined) {
40
+ return "option<" + describeSchema(inner$1) + ">";
40
41
  } else {
41
42
  return "unknown";
42
43
  }
@@ -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,
@@ -63,7 +63,12 @@ module Make = (Spec: Spec, Config: Config) => {
63
63
  ~comp="CommandPublisher",
64
64
  `send: bufferSize: ${bufferSizeStr}, chunk: ${chunkCountStr}, size: ${sizeStr}`,
65
65
  )->Effect.runSync
66
- let commandsToSend = buffer->Array.toSpliced(~start=0, ~remove=size, ~insert=[])
66
+ // Take the first `size` commands and remove them from the buffer.
67
+ // (`toSpliced` is non-mutating and returns the *remainder*, so the buffer
68
+ // never drained: send() recursed on the same buffer and published the
69
+ // wrong slice. `slice` captures the chunk; `splice` drains it in place.)
70
+ let commandsToSend = buffer->Array.slice(~start=0, ~end=size)
71
+ buffer->Array.splice(~start=0, ~remove=size, ~insert=[])
67
72
  let promise = Config.publishCommands(Spec.name, commandsToSend->toJsons)
68
73
  running := Some(promise)
69
74
  switch await promise {
@@ -82,7 +87,12 @@ module Make = (Spec: Spec, Config: Config) => {
82
87
  | SendAllInOneChunk =>
83
88
  let size = buffer->Array.length
84
89
  if size > 0 {
85
- let commandsToSend = buffer->Array.toSpliced(~start=0, ~remove=size, ~insert=[])
90
+ // Take the first `size` commands and remove them from the buffer.
91
+ // (`toSpliced` is non-mutating and returns the *remainder*, so the buffer
92
+ // never drained: send() recursed on the same buffer and published the
93
+ // wrong slice. `slice` captures the chunk; `splice` drains it in place.)
94
+ let commandsToSend = buffer->Array.slice(~start=0, ~end=size)
95
+ buffer->Array.splice(~start=0, ~remove=size, ~insert=[])
86
96
  let promise = Config.publishCommands(Spec.name, commandsToSend->toJsons)
87
97
  running := Some(promise)
88
98
  switch await promise {
@@ -115,19 +125,19 @@ module Make = (Spec: Spec, Config: Config) => {
115
125
 
116
126
  let clear = () => {
117
127
  EffectLogger.logDebug(~comp="CommandPublisher", "clear")->Effect.runSync
118
- let _ = buffer->Array.removeInPlace(0)
128
+ // Truncate the whole buffer. `removeInPlace(0)` is `splice(0, 1)` — it drops
129
+ // only the first element, so the rest leaked out on a later publish.
130
+ buffer->Array.splice(~start=0, ~remove=buffer->Array.length, ~insert=[])
119
131
  flush := false
120
132
  }
121
133
 
122
134
  let flush = async () => {
123
135
  EffectLogger.logDebug(~comp="CommandPublisher", "flush")->Effect.runSync
124
136
  flush := true
125
- switch running.contents {
126
- | None => await send()
127
- | _ =>
128
- while running.contents->Option.isNone {
129
- await finishRunning()
130
- }
131
- }
137
+ // `send` already awaits any in-flight publish (finishRunning) before draining
138
+ // the buffer, and flush:=true forces a partial final chunk. The previous
139
+ // in-flight arm looped on `running.contents->Option.isNone` — dead, since this
140
+ // path only ran when running was Some — so buffered commands were never sent.
141
+ await send()
132
142
  }
133
143
  }
@@ -54,7 +54,8 @@ function Make(Spec) {
54
54
  if (size <= 0) {
55
55
  return;
56
56
  }
57
- let commandsToSend = buffer.toSpliced(0, size);
57
+ let commandsToSend = buffer.slice(0, size);
58
+ buffer.splice(0, size);
58
59
  let promise = Config.publishCommands(Spec.name, toJsons(commandsToSend));
59
60
  running.contents = promise;
60
61
  let exit = 0;
@@ -88,7 +89,8 @@ function Make(Spec) {
88
89
  let bufferSizeStr = buffer.length.toString();
89
90
  let chunkCountStr = chunkCount.contents.toString();
90
91
  Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, `send: bufferSize: ` + bufferSizeStr + `, chunk: ` + chunkCountStr + `, size: ` + sizeStr));
91
- let commandsToSend$1 = buffer.toSpliced(0, size$1);
92
+ let commandsToSend$1 = buffer.slice(0, size$1);
93
+ buffer.splice(0, size$1);
92
94
  let promise$1 = Config.publishCommands(Spec.name, toJsons(commandsToSend$1));
93
95
  running.contents = promise$1;
94
96
  let exit$1 = 0;
@@ -127,19 +129,13 @@ function Make(Spec) {
127
129
  };
128
130
  let clear = () => {
129
131
  Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, "clear"));
130
- buffer.splice(0, 1);
132
+ buffer.splice(0, buffer.length);
131
133
  flush.contents = false;
132
134
  };
133
135
  let flush$1 = async () => {
134
136
  Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, "flush"));
135
137
  flush.contents = true;
136
- let match = running.contents;
137
- if (match === undefined) {
138
- return await send();
139
- }
140
- while (Stdlib_Option.isNone(running.contents)) {
141
- await finishRunning();
142
- };
138
+ return await send();
143
139
  };
144
140
  return {
145
141
  buffer: buffer,
@@ -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() {
@@ -1,2 +1,2 @@
1
1
  let containsByPredicate: (array<'a>, 'a => bool) => bool = (arr, predicate) =>
2
- arr->Array.find(predicate)->Option.isNone
2
+ arr->Array.find(predicate)->Option.isSome
@@ -3,7 +3,7 @@
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
4
 
5
5
  function containsByPredicate(arr, predicate) {
6
- return Stdlib_Option.isNone(arr.find(predicate));
6
+ return Stdlib_Option.isSome(arr.find(predicate));
7
7
  }
8
8
 
9
9
  export {