@reventlessdev/reventless-local 3.0.0-alpha.114 → 3.0.0-alpha.116

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 (65) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +116 -67
  4. package/src/Platform.res.mjs +114 -190
  5. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +102 -17
  6. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +105 -22
  7. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +123 -40
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +96 -24
  9. package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
  10. package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
  11. package/src/adapter/EventLog/EventLogStorage_InMemory.res +24 -3
  12. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +21 -4
  13. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +148 -27
  14. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +144 -34
  15. package/src/adapter/LocalBus.res +85 -3
  16. package/src/adapter/LocalBus.res.mjs +220 -66
  17. package/src/adapter/ProjectionCheckpoint.res +350 -0
  18. package/src/adapter/ProjectionCheckpoint.res.mjs +327 -0
  19. package/src/adapter/ProjectionPending.res +60 -0
  20. package/src/adapter/ProjectionPending.res.mjs +64 -0
  21. package/src/adapter/QueryDb/QueryDbListQuery.res +261 -0
  22. package/src/adapter/QueryDb/QueryDbListQuery.res.mjs +212 -0
  23. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +54 -286
  24. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +28 -190
  25. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +128 -10
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +123 -23
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +187 -12
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +120 -7
  29. package/src/adapter/SqliteDriver.res +17 -2
  30. package/src/adapter/SqliteDriver.res.mjs +10 -2
  31. package/src/components/DcbEventLog_Builder.res +7 -1
  32. package/src/components/DcbEventLog_Builder.res.mjs +3 -3
  33. package/src/components/ReadModel_Builder.res +3 -1
  34. package/src/components/ReadModel_Builder.res.mjs +13 -4
  35. package/src/components/StateViewSlice_Builder.res +4 -1
  36. package/src/components/StateViewSlice_Builder.res.mjs +9 -3
  37. package/src/test/Mocks/MockEventLogStorage.res +20 -3
  38. package/src/test/Mocks/MockEventLogStorage.res.mjs +25 -3
  39. package/tests/PluginEventDecodeTest.res +51 -0
  40. package/tests/PluginEventDecodeTest.res.mjs +63 -0
  41. package/tests/adapter/BackendParityTest.res +64 -0
  42. package/tests/adapter/BackendParityTest.res.mjs +45 -0
  43. package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
  44. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
  45. package/tests/adapter/DcbEventLogStorageTest.res +46 -0
  46. package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
  47. package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
  48. package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
  49. package/tests/adapter/EventLogStorageSqliteTest.res +132 -2
  50. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +195 -1
  51. package/tests/adapter/LocalBusPubSubTest.res +31 -0
  52. package/tests/adapter/LocalBusPubSubTest.res.mjs +28 -0
  53. package/tests/adapter/ProjectionCheckpointTest.res +422 -0
  54. package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
  55. package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
  56. package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
  57. package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
  58. package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
  59. package/tests/adapter/QueryDbListResolverTest.res +76 -0
  60. package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
  61. package/tests/components/aggregate/AggregateFixtures.res +4 -0
  62. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
  63. package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
  64. package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
  65. package/tests/plugin/PluginBehavior_GWT.res.mjs +1 -0
@@ -85,3 +85,36 @@ module Make = (Bus: LocalBus.T) => {
85
85
  []
86
86
  }
87
87
  }
88
+
89
+ // Projection-flavoured channel: identical wiring to `Make`, plus registers the
90
+ // resolved handler in the Bus projection catch-up registry so the SQLite
91
+ // backend's startup catch-up (ProjectionCheckpoint) can replay missed stored
92
+ // events into it. Only projection collectors (the ReadModel builders) use this
93
+ // — automation/translation collectors must never receive catch-up events (they
94
+ // would re-run side effects and re-dispatch commands), so they stay on `Make`.
95
+ module MakeProjection = (Bus: LocalBus.T) => {
96
+ module Base = Make(Bus)
97
+
98
+ type callbackEvent = Base.callbackEvent
99
+ type channelParts = Base.channelParts
100
+ type runtimeParts = Base.runtimeParts
101
+
102
+ let make = Base.make
103
+
104
+ let connect: ReventlessCore.EventCollector_Adapter.connect<
105
+ callbackEvent,
106
+ 'context,
107
+ channelParts,
108
+ runtimeParts,
109
+ > = (~name, ~channelSpecs, ~runtime, ~opts) => {
110
+ let _reg =
111
+ runtime.parts.handlerDeferred
112
+ ->Deferred.await_
113
+ ->Effect.flatMap(handler =>
114
+ Effect.sync(() => Bus.registerProjectionCatchupHandler(name, handler))
115
+ )
116
+ ->Effect.runPromise
117
+ ->ignore
118
+ Base.connect(~name, ~channelSpecs, ~runtime, ~opts)
119
+ }
120
+ }
@@ -39,7 +39,51 @@ function Make(Bus) {
39
39
  };
40
40
  }
41
41
 
42
+ function MakeProjection(Bus) {
43
+ let make = (param, eventTopics, param$1) => {
44
+ let eventTopicResources = Object.values(eventTopics).flatMap(outputs => outputs.resources);
45
+ return {
46
+ parts: undefined,
47
+ resources: eventTopicResources,
48
+ enqueueEvent: Pulumi.output((param, param$1, param$2) => Promise.resolve()),
49
+ handleChannelEvent: handleEvents => Pulumi.output((json, _ctx) => Effect.map(handleEvents(Stream.fromIterable([json])), () => {}))
50
+ };
51
+ };
52
+ let connect = (name, channelSpecs, runtime, param) => {
53
+ Effect.runPromise(Effect.flatMap(Deferred.await(runtime.parts.handlerDeferred), handler => Effect.sync(() => Bus.registerEventCollectorHandler(name, handler))));
54
+ channelSpecs.forEach(param => {
55
+ Object.values(param.eventTopics).forEach(topicOutputs => {
56
+ topicOutputs.resources.forEach(resource => {
57
+ resource.name.apply(topicName => {
58
+ let drainEffect = Effect.scoped(Effect.flatMap(Bus.subscribeToEventStream(topicName), stream => Stream.runForEach(stream, msg => Effect.zipRight(Effect.promise(async () => {
59
+ let handler = await Effect.runPromise(Deferred.await(runtime.parts.handlerDeferred));
60
+ return await handler(msg.json, undefined);
61
+ }), msg.done_))));
62
+ Effect.runFork(drainEffect);
63
+ Effect.runPromise(runtime.parts.subscriptionLatch.open);
64
+ });
65
+ });
66
+ });
67
+ });
68
+ return [];
69
+ };
70
+ let Base = {
71
+ make: make,
72
+ connect: connect
73
+ };
74
+ let connect$1 = (name, channelSpecs, runtime, opts) => {
75
+ Effect.runPromise(Effect.flatMap(Deferred.await(runtime.parts.handlerDeferred), handler => Effect.sync(() => Bus.registerProjectionCatchupHandler(name, handler))));
76
+ return connect(name, channelSpecs, runtime, opts);
77
+ };
78
+ return {
79
+ Base: Base,
80
+ make: make,
81
+ connect: connect$1
82
+ };
83
+ }
84
+
42
85
  export {
43
86
  Make,
87
+ MakeProjection,
44
88
  }
45
89
  /* effect/Effect Not a pure module */
@@ -12,12 +12,16 @@ let makeMemoryStorage = (~name as _name, ~opts as _) => {
12
12
  ->Stm.commit
13
13
  ->Effect.runSync
14
14
 
15
+ // Keep-one snapshot per aggregate id. Node.js is single-threaded and snapshot
16
+ // writes are fire-and-forget upserts, so a plain dict suffices (no Stm).
17
+ let snapshots: dict<ReventlessCore.EventLog.snapshot> = Dict.make()
18
+
15
19
  let append: ReventlessCore.EventLog.append<string, JSON.t> = (seqNr, id, jsons) =>
16
20
  Stm.TRef.modify(eventsRef, events => {
17
21
  let existing = events->Dict.get(id)->Option.getOr([])
18
22
  let currentCount = existing->Array.length
19
23
  if seqNr != currentCount {
20
- (Error("conflict"), events)
24
+ (Error(ReventlessCore.EventLog.Conflict), events)
21
25
  } else {
22
26
  events->Dict.set(id, existing->Array.concat(jsons))
23
27
  (Ok(), events)
@@ -28,10 +32,17 @@ let makeMemoryStorage = (~name as _name, ~opts as _) => {
28
32
 
29
33
  // The in-memory array is already resident; expose it as a stream for API uniformity.
30
34
  // The real performance gain from streaming comes from the DynamoDB adapter.
31
- let replayStream: string => Stream.t<JSON.t, string, unit> = id =>
35
+ // Events are stored contiguously from seq 0, so the array index equals the
36
+ // sequence number and `fromSeq` is a plain slice offset.
37
+ let replayStream = (id, ~fromSeq=0) =>
32
38
  Stm.TRef.get(eventsRef)
33
39
  ->Stm.commit
34
- ->Effect.map(events => events->Dict.get(id)->Option.getOr([]))
40
+ ->Effect.map(events =>
41
+ events
42
+ ->Dict.get(id)
43
+ ->Option.getOr([])
44
+ ->Array.filterWithIndex((_, i) => i >= fromSeq)
45
+ )
35
46
  ->Stream.fromEffect
36
47
  ->Stream.flatMap(arr => Stream.fromIterable(arr))
37
48
 
@@ -39,6 +50,14 @@ let makeMemoryStorage = (~name as _name, ~opts as _) => {
39
50
  let replay: ReventlessCore.EventLog.replay<string, JSON.t> = id =>
40
51
  replayStream(id)->Stream.runCollect->Effect.runPromise
41
52
 
53
+ let latestSnapshot: ReventlessCore.EventLog.latestSnapshot<string> = async id =>
54
+ Ok(snapshots->Dict.get(id))
55
+
56
+ let writeSnapshot: ReventlessCore.EventLog.writeSnapshot<string> = async (id, snap) => {
57
+ snapshots->Dict.set(id, snap)
58
+ Ok()
59
+ }
60
+
42
61
  // Appends each stream item sequentially to storage.
43
62
  // Node.js is single-threaded so a plain ref is safe for the seqNr counter.
44
63
  let appendStream: ReventlessCore.EventLog.appendStream<string, JSON.t> = (startingSeqNr, id, stream) => {
@@ -76,6 +95,8 @@ let makeMemoryStorage = (~name as _name, ~opts as _) => {
76
95
  replay,
77
96
  replayStream,
78
97
  appendStream,
98
+ latestSnapshot,
99
+ writeSnapshot,
79
100
  }),
80
101
  },
81
102
  )
@@ -13,6 +13,7 @@ import * as EventLogStorage_Sqlite$ReventlessLocal from "./EventLogStorage_Sqlit
13
13
 
14
14
  function makeMemoryStorage(_name, param) {
15
15
  let eventsRef = Effect.runSync(STM.commit(TRef.make({})));
16
+ let snapshots = {};
16
17
  let append = (seqNr, id, jsons) => Effect.runPromise(STM.commit(TRef.modify(eventsRef, events => {
17
18
  let existing = Stdlib_Option.getOr(events[id], []);
18
19
  let currentCount = existing.length;
@@ -20,7 +21,7 @@ function makeMemoryStorage(_name, param) {
20
21
  return [
21
22
  {
22
23
  TAG: "Error",
23
- _0: "conflict"
24
+ _0: "Conflict"
24
25
  },
25
26
  events
26
27
  ];
@@ -35,8 +36,22 @@ function makeMemoryStorage(_name, param) {
35
36
  ];
36
37
  }
37
38
  })));
38
- let replayStream = id => Stream$1.flatMap(Stream$1.fromEffect(Effect.map(STM.commit(TRef.get(eventsRef)), events => Stdlib_Option.getOr(events[id], []))), arr => Stream$1.fromIterable(arr));
39
- let replay = id => Effect.runPromise(Stream.runCollect(replayStream(id)));
39
+ let replayStream = (id, fromSeqOpt) => {
40
+ let fromSeq = fromSeqOpt !== undefined ? fromSeqOpt : 0;
41
+ return Stream$1.flatMap(Stream$1.fromEffect(Effect.map(STM.commit(TRef.get(eventsRef)), events => Stdlib_Option.getOr(events[id], []).filter((param, i) => i >= fromSeq))), arr => Stream$1.fromIterable(arr));
42
+ };
43
+ let replay = id => Effect.runPromise(Stream.runCollect(replayStream(id, undefined)));
44
+ let latestSnapshot = async id => ({
45
+ TAG: "Ok",
46
+ _0: snapshots[id]
47
+ });
48
+ let writeSnapshot = async (id, snap) => {
49
+ snapshots[id] = snap;
50
+ return {
51
+ TAG: "Ok",
52
+ _0: undefined
53
+ };
54
+ };
40
55
  let appendStream = (startingSeqNr, id, stream) => {
41
56
  let seqNrRef = {
42
57
  contents: startingSeqNr
@@ -79,7 +94,9 @@ function makeMemoryStorage(_name, param) {
79
94
  append: append,
80
95
  replay: replay,
81
96
  replayStream: replayStream,
82
- appendStream: appendStream
97
+ appendStream: appendStream,
98
+ latestSnapshot: latestSnapshot,
99
+ writeSnapshot: writeSnapshot
83
100
  })
84
101
  }
85
102
  ];
@@ -16,10 +16,18 @@
16
16
 
17
17
  open ReventlessCore
18
18
 
19
- let ensureSchema = (db: SqliteDriver.t) =>
19
+ let ensureSchema = (db: SqliteDriver.t) => {
20
20
  db->SqliteDriver.exec(
21
21
  "CREATE TABLE IF NOT EXISTS event_log (log_name TEXT NOT NULL, aggregate_id TEXT NOT NULL, seq_nr INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (log_name, aggregate_id, seq_nr))",
22
22
  )
23
+ // Keep-one aggregate-state snapshots (docs/plans/aggregate-snapshotting.md):
24
+ // one row per (log, aggregate), overwritten on every snapshot write. `state`
25
+ // is the JSON-encoded fold of events seq 0..seq_nr-1; `schema_hash` lets the
26
+ // consumer detect a state-schema drift and fall back to full replay.
27
+ db->SqliteDriver.exec(
28
+ "CREATE TABLE IF NOT EXISTS snapshot (log_name TEXT NOT NULL, aggregate_id TEXT NOT NULL, seq_nr INTEGER NOT NULL, state TEXT NOT NULL, schema_hash TEXT NOT NULL, PRIMARY KEY (log_name, aggregate_id))",
29
+ )
30
+ }
23
31
 
24
32
  // Decodes the JSON column to a JSON.t. Returns JSON.Null on unparseable input
25
33
  // (should not happen for data we wrote ourselves).
@@ -53,15 +61,27 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
53
61
  let insertStmt = db->SqliteDriver.prepare(
54
62
  "INSERT INTO event_log(log_name, aggregate_id, seq_nr, payload) VALUES(?,?,?,?)",
55
63
  )
56
- let countStmt = db->SqliteDriver.prepare(
57
- "SELECT COUNT(*) AS c FROM event_log WHERE log_name=? AND aggregate_id=?",
64
+ // Expected next seq_nr for an aggregate. Events are always appended
65
+ // contiguously from seq 0 (the OCC check below forbids gaps), so
66
+ // `MAX(seq_nr)+1` equals the row count — but it reads the rightmost leaf of the
67
+ // (log_name, aggregate_id, seq_nr) PK index in O(log n) instead of `COUNT(*)`
68
+ // scanning every row for the aggregate. `COALESCE(…, -1)+1` yields 0 when empty.
69
+ let nextSeqStmt = db->SqliteDriver.prepare(
70
+ "SELECT COALESCE(MAX(seq_nr), -1) + 1 AS c FROM event_log WHERE log_name=? AND aggregate_id=?",
58
71
  )
59
72
  let selectByIdStmt = db->SqliteDriver.prepare(
60
- "SELECT payload FROM event_log WHERE log_name=? AND aggregate_id=? ORDER BY seq_nr ASC",
73
+ "SELECT payload FROM event_log WHERE log_name=? AND aggregate_id=? AND seq_nr >= ? ORDER BY seq_nr ASC",
74
+ )
75
+ let lastRowidStmt = db->SqliteDriver.prepare("SELECT last_insert_rowid() AS r")
76
+ let upsertSnapshotStmt = db->SqliteDriver.prepare(
77
+ "INSERT OR REPLACE INTO snapshot(log_name, aggregate_id, seq_nr, state, schema_hash) VALUES(?,?,?,?,?)",
78
+ )
79
+ let selectSnapshotStmt = db->SqliteDriver.prepare(
80
+ "SELECT seq_nr, state, schema_hash FROM snapshot WHERE log_name=? AND aggregate_id=?",
61
81
  )
62
82
 
63
- let currentCount = (id: string): int =>
64
- switch countStmt->SqliteDriver.get([JSON.Encode.string(name), JSON.Encode.string(id)]) {
83
+ let expectedNextSeq = (id: string): int =>
84
+ switch nextSeqStmt->SqliteDriver.get([JSON.Encode.string(name), JSON.Encode.string(id)]) {
65
85
  | Some(row) =>
66
86
  switch row->Dict.get("c") {
67
87
  | Some(JSON.Number(n)) => Float.toInt(n)
@@ -70,10 +90,14 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
70
90
  | None => 0
71
91
  }
72
92
 
73
- let append: EventLog.append<string, JSON.t> = async (seqNr, id, jsons) => {
93
+ // `track=false` for appendStream: bulk-replayed batches never flow through
94
+ // the EventTopic publish cycle, so they must not enter the projection pending
95
+ // set — an entry nobody resolves would pin the checkpoint low-watermark
96
+ // forever (see ProjectionCheckpoint.res).
97
+ let appendTracked = async (~track, seqNr, id, jsons: array<JSON.t>) => {
74
98
  try {
75
- db->SqliteDriver.transaction(() => {
76
- let existing = currentCount(id)
99
+ let lastRowid = db->SqliteDriver.transaction(() => {
100
+ let existing = expectedNextSeq(id)
77
101
  if seqNr != existing {
78
102
  throw(Failure("conflict"))
79
103
  }
@@ -85,17 +109,62 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
85
109
  JSON.Encode.string(JSON.stringify(json)),
86
110
  ])
87
111
  })
112
+ switch lastRowidStmt->SqliteDriver.get([]) {
113
+ | Some(row) =>
114
+ switch row->Dict.get("r") {
115
+ | Some(JSON.Number(n)) => Float.toInt(n)
116
+ | _ => 0
117
+ }
118
+ | None => 0
119
+ }
88
120
  })
121
+ // Register the committed batch as appended-but-not-yet-published for the
122
+ // projection checkpoint low-watermark; Platform's afterPublish hook
123
+ // resolves the msgIds once the publish cycle completes. A transaction's
124
+ // inserts get contiguous rowids ending at lastRowid.
125
+ if track && lastRowid > 0 {
126
+ let count = jsons->Array.length
127
+ ProjectionPending.trackAppended(
128
+ ~axis=ProjectionPending.Aggregate,
129
+ jsons
130
+ ->Array.mapWithIndex((json, i) =>
131
+ json
132
+ ->JSON.Decode.object
133
+ ->Option.flatMap(d => d->Dict.get("msgId"))
134
+ ->Option.flatMap(JSON.Decode.string)
135
+ ->Option.map(msgId => (msgId, lastRowid - count + 1 + i))
136
+ )
137
+ ->Array.filterMap(x => x),
138
+ )
139
+ }
89
140
  Ok()
90
141
  } catch {
91
- | Failure(msg) => Error(msg)
92
- | _ => Error("conflict")
142
+ // The deliberate seq_nr check above throws Failure("conflict"); a lost race
143
+ // shows up as a PRIMARY KEY violation on (log_name, aggregate_id, seq_nr)
144
+ // also a genuine OCC conflict. Every OTHER failure (disk full, SQL error,
145
+ // locked db) is a real StorageFailure, not the retryable Conflict sentinel.
146
+ | Failure(msg) =>
147
+ msg == "conflict"
148
+ ? Error(ReventlessCore.EventLog.Conflict)
149
+ : Error(ReventlessCore.EventLog.StorageFailure(msg))
150
+ | exn =>
151
+ let msg = exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("")
152
+ msg->String.includes("constraint failed")
153
+ ? Error(ReventlessCore.EventLog.Conflict)
154
+ : Error(ReventlessCore.EventLog.StorageFailure(msg == "" ? "storage error" : msg))
93
155
  }
94
156
  }
95
157
 
96
- let replayArray = (id: string): array<JSON.t> =>
158
+ let append: EventLog.append<string, JSON.t> = (seqNr, id, jsons) =>
159
+ appendTracked(~track=true, seqNr, id, jsons)
160
+
161
+ let replayArray = (id: string, ~fromSeq=0): array<JSON.t> =>
97
162
  selectByIdStmt
98
- ->SqliteDriver.all([JSON.Encode.string(name), JSON.Encode.string(id)])
163
+ ->SqliteDriver.all([
164
+ JSON.Encode.string(name),
165
+ JSON.Encode.string(id),
166
+ JSON.Encode.int(fromSeq),
167
+ ])
99
168
  ->Array.map(decodePayload)
100
169
 
101
170
  let replay: EventLog.replay<string, JSON.t> = async id => replayArray(id)
@@ -103,23 +172,73 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
103
172
  // node:sqlite's iterate() could back a lazy stream, but the current Stream
104
173
  // API only has array-based fromIterable. Small events, dev-only backend →
105
174
  // materialise via all() and wrap. Swap to a lazy adapter if it matters.
106
- let replayStream: string => Stream.t<JSON.t, string, unit> = id =>
107
- replayArray(id)->Stream.fromIterable
108
-
109
- let appendStream: EventLog.appendStream<string, JSON.t> = (startingSeqNr, id, stream) => {
110
- let seqNrRef = ref(startingSeqNr)
111
- stream->Stream.runForEach(json => {
112
- let currentSeq = seqNrRef.contents
113
- Effect.promise(() => append(currentSeq, id, [json]))->Effect.flatMap(result =>
175
+ let replayStream = (id, ~fromSeq=?) => replayArray(id, ~fromSeq=?fromSeq)->Stream.fromIterable
176
+
177
+ let latestSnapshot: EventLog.latestSnapshot<string> = async id =>
178
+ try {
179
+ switch selectSnapshotStmt->SqliteDriver.get([
180
+ JSON.Encode.string(name),
181
+ JSON.Encode.string(id),
182
+ ]) {
183
+ | Some(row) =>
184
+ switch (row->Dict.get("seq_nr"), row->Dict.get("state"), row->Dict.get("schema_hash")) {
185
+ | (Some(JSON.Number(seq)), Some(JSON.String(stateStr)), Some(JSON.String(schemaHash))) =>
186
+ Ok(
187
+ Some({
188
+ EventLog.seqNr: Float.toInt(seq),
189
+ state: JSON.parseOrThrow(stateStr),
190
+ schemaHash,
191
+ }),
192
+ )
193
+ | _ => Error("snapshot row has unexpected shape")
194
+ }
195
+ | None => Ok(None)
196
+ }
197
+ } catch {
198
+ | exn =>
199
+ Error(
200
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("snapshot read error"),
201
+ )
202
+ }
203
+
204
+ let writeSnapshot: EventLog.writeSnapshot<string> = async (id, snap) =>
205
+ try {
206
+ upsertSnapshotStmt->SqliteDriver.run([
207
+ JSON.Encode.string(name),
208
+ JSON.Encode.string(id),
209
+ JSON.Encode.int(snap.seqNr),
210
+ JSON.Encode.string(JSON.stringify(snap.state)),
211
+ JSON.Encode.string(snap.schemaHash),
212
+ ])
213
+ Ok()
214
+ } catch {
215
+ | exn =>
216
+ Error(
217
+ exn
218
+ ->JsExn.fromException
219
+ ->Option.flatMap(JsExn.message)
220
+ ->Option.getOr("snapshot write error"),
221
+ )
222
+ }
223
+
224
+ // Collect the stream and hand the whole batch to `append`, which inserts every
225
+ // event under one transaction with a single OCC check (vs the old per-element
226
+ // append: one transaction + one seq lookup per event over a bulk replay). This
227
+ // also makes the replay atomic — all events land or none do, rather than
228
+ // leaving a partial prefix committed if a later element hit a raced PK.
229
+ let appendStream: EventLog.appendStream<string, JSON.t> = (startingSeqNr, id, stream) =>
230
+ stream
231
+ ->Stream.runCollect
232
+ ->Effect.flatMap(jsons =>
233
+ Effect.promise(() => appendTracked(~track=false, startingSeqNr, id, jsons))->Effect.flatMap(result =>
114
234
  switch result {
115
- | Ok() =>
116
- seqNrRef := currentSeq + 1
117
- Effect.succeed()
118
- | Error(msg) => Effect.fail(msg)
235
+ | Ok() => Effect.succeed()
236
+ // appendStream's error channel is a string; map the typed append error.
237
+ | Error(ReventlessCore.EventLog.Conflict) => Effect.fail("conflict")
238
+ | Error(StorageFailure(msg)) => Effect.fail(msg)
119
239
  }
120
240
  )
121
- })
122
- }
241
+ )
123
242
 
124
243
  (
125
244
  name,
@@ -131,6 +250,8 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
131
250
  replay,
132
251
  replayStream,
133
252
  appendStream,
253
+ latestSnapshot,
254
+ writeSnapshot,
134
255
  }),
135
256
  },
136
257
  )