@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.137 (2026-07-03)
7
+
8
+ ### Features
9
+
10
+ * **core,local,aws:** EventLog snapshot storage surface, all backends (aggregate-snapshotting plan, steps 3+4) ([b6e50e2](https://github.com/ReventlessDev/reventless-core/commit/b6e50e2e5fe50b7372e54811f883fa91f6758dd1))
11
+ * **core:** in-process aggregate replay cache (aggregate-snapshotting plan, phase 1) ([89c75f3](https://github.com/ReventlessDev/reventless-core/commit/89c75f33116e929cb8f4208b33498bab42db45e2))
12
+ * **local:** DCB-axis projection checkpoints + startup catch-up (plan B5) ([8f9b11d](https://github.com/ReventlessDev/reventless-core/commit/8f9b11d24627bb6cc105d37af4974745444b80ba))
13
+ ### Performance Improvements
14
+
15
+ * **core:** memoize message schemas, lazy projection logs, fail-closed slice cache (plan B6) ([b9e423c](https://github.com/ReventlessDev/reventless-core/commit/b9e423cb27932c4b6b5a123d3ae80d2239341d61))
16
+
17
+
18
+ # 3.0.0-alpha.136 (2026-07-02)
19
+
20
+ ### Bug Fixes
21
+
22
+ * **core,local:** projection/publisher/bus correctness (plan A5,A7) ([86e27a5](https://github.com/ReventlessDev/reventless-core/commit/86e27a5d68873d7dd9e746f9b4126f4d5bad63c9))
23
+
24
+
6
25
  # 3.0.0-alpha.135 (2026-06-29)
7
26
 
8
27
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.135",
3
+ "version": "3.0.0-alpha.137",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,22 +28,22 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.23",
31
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
32
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
33
- "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
34
- "@reventlessdev/rescript-jest": "1.0.0-alpha.3",
33
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
35
34
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
35
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
37
37
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
38
38
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.21",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.62",
41
- "@reventlessdev/reventless-infra": "3.0.0-alpha.82"
39
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.23",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.64",
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.84"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "^12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.48"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.50"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "^12.3.0"
package/src/Message.res CHANGED
@@ -18,18 +18,51 @@ module type Service = {
18
18
 
19
19
  include Reventless.Message
20
20
 
21
+ // Compose the wrapper schema once per (idSchema, payloadSchema) reference pair.
22
+ // Both are module-level constants per message type, and sury schemas are built
23
+ // to be reused across many parse/serialize calls — so the previous fresh
24
+ // `S.object` on every decode/encode was pure allocation on the hottest path in
25
+ // the framework. The cache is keyed by object identity (nested WeakMaps), so a
26
+ // dropped schema is still collectable. `build` runs only on a miss.
27
+ //
28
+ // The raw closure is typed monomorphically over an opaque type (concrete → no
29
+ // weak-var value-restriction problem); the locally-abstract-typed wrapper below
30
+ // is a syntactic function, so it generalizes and `Obj.magic` bridges the two.
31
+ // This keeps the public `toEventSchema'` / `toCommandSchema'` fully polymorphic.
32
+ type opaqueSchema
33
+ let _rawMemoize: (
34
+ opaqueSchema,
35
+ opaqueSchema,
36
+ (opaqueSchema, opaqueSchema) => opaqueSchema,
37
+ ) => opaqueSchema = %raw(`(function(){
38
+ const outer = new WeakMap();
39
+ return function(a, b, build){
40
+ let inner = outer.get(a);
41
+ if (inner === undefined) { inner = new WeakMap(); outer.set(a, inner); }
42
+ let v = inner.get(b);
43
+ if (v === undefined) { v = build(a, b); inner.set(b, v); }
44
+ return v;
45
+ };
46
+ })()`)
47
+ let memoizeBySchemaPair = (type a b c, a: a, b: b, build: (a, b) => c): c =>
48
+ _rawMemoize(Obj.magic(a), Obj.magic(b), Obj.magic(build))->Obj.magic
49
+
21
50
  let toEventSchema' = (idSchema, eventSchema) =>
22
- S.object(s => {
23
- id: s.field("id", idSchema),
24
- meta: s.field("meta", metaSchema),
25
- event: s.field("event", eventSchema),
26
- })
51
+ memoizeBySchemaPair(idSchema, eventSchema, (idSchema, eventSchema) =>
52
+ S.object(s => {
53
+ id: s.field("id", idSchema),
54
+ meta: s.field("meta", metaSchema),
55
+ event: s.field("event", eventSchema),
56
+ })
57
+ )
27
58
  let toCommandSchema' = (idSchema, commandSchema) =>
28
- S.object(s => {
29
- id: s.field("id", idSchema),
30
- meta: s.field("meta", metaSchema),
31
- command: s.field("command", commandSchema),
32
- })
59
+ memoizeBySchemaPair(idSchema, commandSchema, (idSchema, commandSchema) =>
60
+ S.object(s => {
61
+ id: s.field("id", idSchema),
62
+ meta: s.field("meta", metaSchema),
63
+ command: s.field("command", commandSchema),
64
+ })
65
+ )
33
66
 
34
67
  let decodeEvent' = (json, idSchema, eventSchema) =>
35
68
  json->S.parseJsonOrThrow(toEventSchema'(idSchema, eventSchema))
@@ -12,20 +12,35 @@ import * as Logger$ReventlessCore from "./util/Logger.res.mjs";
12
12
 
13
13
  let logger = Logger$ReventlessCore.fromEnv();
14
14
 
15
+ let _rawMemoize = ((function(){
16
+ const outer = new WeakMap();
17
+ return function(a, b, build){
18
+ let inner = outer.get(a);
19
+ if (inner === undefined) { inner = new WeakMap(); outer.set(a, inner); }
20
+ let v = inner.get(b);
21
+ if (v === undefined) { v = build(a, b); inner.set(b, v); }
22
+ return v;
23
+ };
24
+ })());
25
+
26
+ function memoizeBySchemaPair(a, b, build) {
27
+ return _rawMemoize(a, b, build);
28
+ }
29
+
15
30
  function toEventSchema$p(idSchema, eventSchema) {
16
- return S.object(s => ({
31
+ return _rawMemoize(idSchema, eventSchema, (idSchema, eventSchema) => S.object(s => ({
17
32
  id: s.f("id", idSchema),
18
33
  meta: s.f("meta", Message$Reventless.metaSchema),
19
34
  event: s.f("event", eventSchema)
20
- }));
35
+ })));
21
36
  }
22
37
 
23
38
  function toCommandSchema$p(idSchema, commandSchema) {
24
- return S.object(s => ({
39
+ return _rawMemoize(idSchema, commandSchema, (idSchema, commandSchema) => S.object(s => ({
25
40
  id: s.f("id", idSchema),
26
41
  meta: s.f("meta", Message$Reventless.metaSchema),
27
42
  command: s.f("command", commandSchema)
28
- }));
43
+ })));
29
44
  }
30
45
 
31
46
  function decodeEvent$p(json, idSchema, eventSchema) {
@@ -385,6 +400,8 @@ export {
385
400
  decode,
386
401
  encode,
387
402
  InvalidEvent,
403
+ _rawMemoize,
404
+ memoizeBySchemaPair,
388
405
  toEventSchema$p,
389
406
  toCommandSchema$p,
390
407
  decodeEvent$p,
@@ -16,7 +16,10 @@ module Mapping = {
16
16
  }
17
17
  }
18
18
 
19
- let logAction = str => log.debug(~comp="Projection", str)
19
+ // Lazy: the message thunk (and any `stateToString` it triggers) runs only when
20
+ // debug logging is enabled, so projection actions don't serialize state on the
21
+ // hot path at the default Info level.
22
+ let logAction = makeStr => log.debugLazy(~comp="Projection", makeStr)
20
23
 
21
24
  // ── @displayName overlay ────────────────────────────────────────────────────
22
25
  // When a read model's state schema carries DisplayName metadata, every state
@@ -86,10 +89,16 @@ let applyChanges = async (
86
89
  let addedStates = afterStates->Array.filter(state => addedSubIds->Set.has(state->getSubId))
87
90
  let addedCount = addedStates->Array.length
88
91
 
89
- let changedStates = beforeStates->Array.filterMap(before => {
90
- let beforeSubId = before->getSubId
91
- afterStates->Array.find(after => after->getSubId == beforeSubId && after != before)
92
- })
92
+ // Index the after-states by subId once instead of scanning them per
93
+ // before-state (was O(before × after)).
94
+ let afterBySubId = Dict.make()
95
+ afterStates->Array.forEach(after => afterBySubId->Dict.set(after->getSubId, after))
96
+ let changedStates = beforeStates->Array.filterMap(before =>
97
+ switch afterBySubId->Dict.get(before->getSubId) {
98
+ | Some(after) if after != before => Some(after)
99
+ | _ => None
100
+ }
101
+ )
93
102
  let changedCount = changedStates->Array.length
94
103
 
95
104
  let batchToSave =
@@ -99,16 +108,27 @@ let applyChanges = async (
99
108
  let batchToDelete = deletedSubIds->Array.map(subId => (id, Some((subIdField, subId))))
100
109
  let deletedCount = batchToDelete->Array.length
101
110
 
102
- logAction(
103
- `${action}(${id}): beforeStates:${beforeCount->Int.toString} afterStates:${afterCount->Int.toString} added:${addedCount->Int.toString} changed:${changedCount->Int.toString} deleted:${deletedCount->Int.toString}`,
111
+ logAction(() =>
112
+ `${action}(${id}): beforeStates:${beforeCount->Int.toString} afterStates:${afterCount->Int.toString} added:${addedCount->Int.toString} changed:${changedCount->Int.toString} deleted:${deletedCount->Int.toString}`
104
113
  )
105
114
  let result = await [deleteBatch(batchToDelete), saveBatch(batchToSave)]->Promise.all
106
- switch result {
107
- | _ => Ok() // TODO: Error handling
115
+ // Propagate the first storage failure instead of swallowing it — a failed
116
+ // delete/save was previously reported as a successful projection.
117
+ switch result->Array.find(r =>
118
+ switch r {
119
+ | Error(_) => true
120
+ | Ok(_) => false
121
+ }
122
+ ) {
123
+ | Some(err) => err
124
+ | None => Ok()
108
125
  }
109
126
  }
110
127
 
111
- let stateToString: 'a => string = state => state->JSON.stringifyAny->Option.getOrThrow
128
+ // Best-effort: a state that can't be JSON-serialized (e.g. carries a function)
129
+ // must not crash the projection just to produce a debug line.
130
+ let stateToString: 'a => string = state =>
131
+ state->JSON.stringifyAny->Option.getOr("<unserializable>")
112
132
  let statesToString: array<'a> => string = states =>
113
133
  states->Array.map(stateToString)->Array.joinUnsafe(", ")
114
134
 
@@ -132,17 +152,17 @@ let handleAction = async (
132
152
  ->Effect.runPromise
133
153
  switch action {
134
154
  | Ignore =>
135
- logAction("Ignore")
155
+ logAction(() => "Ignore")
136
156
  Ok()
137
157
 
138
158
  | Create(id, state) =>
139
- logAction(`Create(${id}, ${state->stateToString})`)
159
+ logAction(() => `Create(${id}, ${state->stateToString})`)
140
160
  await save(id, state, Init, None)
141
161
  | CreateMultiState(id, states) =>
142
- logAction(
162
+ logAction(() =>
143
163
  `CreateMultiState(${id}, ${states
144
164
  ->Array.map(state => state->stateToString)
145
- ->Array.joinUnsafe(", ")})`,
165
+ ->Array.joinUnsafe(", ")})`
146
166
  )
147
167
  switch states {
148
168
  | [] => Ok()
@@ -153,23 +173,23 @@ let handleAction = async (
153
173
  }
154
174
  | CreateMany(states) =>
155
175
  let batch = states->Array.map(((id, state)) => (id, state, None))
156
- let statesStr =
157
- batch
158
- ->Array.map(((id, state, _)) => `(${id},${state->stateToString})`)
159
- ->Array.joinUnsafe(", ")
160
- logAction(`CreateMany(${statesStr})`)
176
+ logAction(() =>
177
+ `CreateMany(${batch
178
+ ->Array.map(((id, state, _)) => `(${id},${state->stateToString})`)
179
+ ->Array.joinUnsafe(", ")})`
180
+ )
161
181
  await saveBatch(batch) // TODO: think about using single saves with saveMode Init
162
182
 
163
183
  | Set(id, state) =>
164
- logAction(`Set(${id}, ${state->stateToString})`)
184
+ logAction(() => `Set(${id}, ${state->stateToString})`)
165
185
  await save(id, state, Any, None)
166
186
  | SetMany(ids, set) =>
167
187
  let batch = ids->Array.map(id => (id, set(id), None))
168
- let statesStr =
169
- batch
170
- ->Array.map(((id, state, _)) => `(${id},${state->stateToString})`)
171
- ->Array.joinUnsafe(", ")
172
- logAction(`SetMany(${statesStr})`)
188
+ logAction(() =>
189
+ `SetMany(${batch
190
+ ->Array.map(((id, state, _)) => `(${id},${state->stateToString})`)
191
+ ->Array.joinUnsafe(", ")})`
192
+ )
173
193
  await saveBatch(batch)
174
194
 
175
195
  | Update(id, update) =>
@@ -177,39 +197,39 @@ let handleAction = async (
177
197
  | Ok(states) =>
178
198
  switch states {
179
199
  | [] =>
180
- logAction(`Update Error: No oldState for ${id})`)
200
+ logAction(() => `Update Error: No oldState for ${id})`)
181
201
  Error(ReventlessInfra.QueryDb.StaleState)
182
202
  | [oldState] =>
183
203
  let newState = oldState->update
184
- logAction(`Update(${id}, ${oldState->stateToString} => ${newState->stateToString})`)
204
+ logAction(() => `Update(${id}, ${oldState->stateToString} => ${newState->stateToString})`)
185
205
  await save(id, newState, Overwrite, None)
186
206
  | _ =>
187
- logAction(`Update Error: Multiple oldStates for ${id})`)
207
+ logAction(() => `Update Error: Multiple oldStates for ${id})`)
188
208
  Error(ReventlessInfra.QueryDb.StaleState)
189
209
  }
190
210
  | Error(err) => Error(err)
191
211
  }
192
212
  | UpdateWithDefault(id, default, update) =>
193
- log.debug(~comp="Projection", `UpdateWithDefault(${id}, loading ...`)
213
+ logAction(() => `UpdateWithDefault(${id}, loading ...`)
194
214
  switch await loadAtMost(2, id) {
195
215
  | Ok(states) =>
196
216
  switch states {
197
217
  | [] =>
198
- logAction(`UpdateWithDefault(${id}, default: ${default->stateToString})`)
218
+ logAction(() => `UpdateWithDefault(${id}, default: ${default->stateToString})`)
199
219
  await save(id, default, Init, None)
200
220
  | [oldState] =>
201
221
  let newState = oldState->update
202
- logAction(
203
- `UpdateWithDefault(${id}, ${oldState->stateToString} => ${newState->stateToString})`,
222
+ logAction(() =>
223
+ `UpdateWithDefault(${id}, ${oldState->stateToString} => ${newState->stateToString})`
204
224
  )
205
225
  await save(id, newState, Overwrite, None)
206
226
  | _ =>
207
- logAction(`UpdateWithDefault Error: Multiple oldStates for ${id})`)
227
+ logAction(() => `UpdateWithDefault Error: Multiple oldStates for ${id})`)
208
228
  Error(ReventlessInfra.QueryDb.StaleState)
209
229
  }
210
230
  | Error(err) =>
211
- logAction(
212
- `UpdateWithDefault Error: Couldn't load oldState(s) for ${id}: ${err->QueryDb.storageErrorToString})`,
231
+ logAction(() =>
232
+ `UpdateWithDefault Error: Couldn't load oldState(s) for ${id}: ${err->QueryDb.storageErrorToString})`
213
233
  )
214
234
  Error(err)
215
235
  }
@@ -221,25 +241,27 @@ let handleAction = async (
221
241
  await applyChanges("UpdateMultiState", id, beforeStates, afterStates, operations, subIdConfig)
222
242
 
223
243
  | (Error(err), Some(_)) =>
224
- logAction(
225
- `UpdateMultiState Error: Couldn't load states for ${id}: ${err->QueryDb.storageErrorToString})`,
244
+ logAction(() =>
245
+ `UpdateMultiState Error: Couldn't load states for ${id}: ${err->QueryDb.storageErrorToString})`
226
246
  )
227
247
  Error(err)
228
248
  | (_, None) =>
229
- logAction("UpdateMultiState Error: Missing SubIdConfig !")
249
+ logAction(() => "UpdateMultiState Error: Missing SubIdConfig !")
230
250
  Error(ReventlessInfra.QueryDb.MissingSubIdConfig)
231
251
  }
232
252
  | Delete(id) =>
233
- logAction(`Delete(${id})`)
253
+ logAction(() => `Delete(${id})`)
234
254
  await delete(id, None)
235
255
  | DeleteMany(ids) =>
236
- logAction(`DeleteMany(${ids->Array.joinUnsafe(", ")})`)
256
+ logAction(() => `DeleteMany(${ids->Array.joinUnsafe(", ")})`)
237
257
  await deleteBatch(ids->Array.map(id => (id, None)))
238
258
 
239
- // TODO: add missing actions
259
+ // An action reaching here is one this projection engine doesn't implement.
260
+ // Returning Ok() reported the read-model write as done while silently losing
261
+ // it; surface a storage error instead.
240
262
  | _ =>
241
- logAction("Error: Action not yet supported !")
242
- Ok()
263
+ logAction(() => "Error: projection action not supported")
264
+ Error(ReventlessInfra.QueryDb.NotSavedToStorage("unsupported projection action"))
243
265
  }
244
266
  }
245
267
 
@@ -261,20 +283,27 @@ let actionsWithId = action =>
261
283
 
262
284
  // TODO: add missing actions
263
285
  | _ =>
264
- logAction("Error: Action not yet supported !")
286
+ logAction(() => "Error: Action not yet supported !")
265
287
  []
266
288
  }
267
289
 
268
290
  let groupActionsById = actions => {
269
291
  let allActionsWithId = actions->Array.map(action => action->actionsWithId)->Array.flat
270
- let ids = allActionsWithId->Array.map(((id, _)) => id)
271
- ids
292
+ // One pass into a dict keyed by id (was O(ids × actions): a filterMap over
293
+ // every action per unique id). The id ordering is unchanged — still the sorted
294
+ // Belt.Set order — and each group preserves the actions' original order.
295
+ let groups = Dict.make()
296
+ allActionsWithId->Array.forEach(((id, action)) =>
297
+ switch groups->Dict.get(id) {
298
+ | Some(arr) => arr->Array.push(action)
299
+ | None => groups->Dict.set(id, [action])
300
+ }
301
+ )
302
+ groups
303
+ ->Dict.keysToArray
272
304
  ->Belt.Set.String.fromArray
273
305
  ->Belt.Set.String.toArray
274
- ->Array.map(id => (
275
- id,
276
- allActionsWithId->Array.filterMap(((actionId, action)) => actionId == id ? Some(action) : None),
277
- ))
306
+ ->Array.map(id => (id, groups->Dict.getUnsafe(id)))
278
307
  }
279
308
 
280
309
  let optimizeActions = actions => {
@@ -285,11 +314,10 @@ let optimizeActions = actions => {
285
314
  [action]
286
315
  } else {
287
316
  let lastAction = optimizedActions->Array.getUnsafe(optimizedActionsCount - 1)
288
- let previousActions = if optimizedActionsCount == 1 {
289
- []
290
- } else {
291
- optimizedActions->Array.slice(~start=0, ~end=optimizedActionsCount)
292
- }
317
+ // Everything BEFORE the last action. `~end` is exclusive, so this must be
318
+ // `count - 1`; using `count` kept `lastAction` in the slice while the merged
319
+ // action was also appended, applying the last action twice.
320
+ let previousActions = optimizedActions->Array.slice(~start=0, ~end=optimizedActionsCount - 1)
293
321
 
294
322
  switch (lastAction, action) {
295
323
  // SINGLE STATES