@reventlessdev/reventless-local 3.0.0-alpha.115 → 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 (59) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +6 -6
  3. package/src/Platform.res +91 -17
  4. package/src/Platform.res.mjs +89 -30
  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 +113 -39
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +87 -23
  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 +23 -2
  12. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +20 -3
  13. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +132 -24
  14. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +119 -37
  15. package/src/adapter/LocalBus.res +62 -0
  16. package/src/adapter/LocalBus.res.mjs +120 -0
  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 +32 -6
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +30 -14
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +163 -11
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +101 -3
  29. package/src/adapter/SqliteDriver.res +11 -1
  30. package/src/adapter/SqliteDriver.res.mjs +5 -1
  31. package/src/components/ReadModel_Builder.res +3 -1
  32. package/src/components/ReadModel_Builder.res.mjs +13 -4
  33. package/src/components/StateViewSlice_Builder.res +4 -1
  34. package/src/components/StateViewSlice_Builder.res.mjs +9 -3
  35. package/src/test/Mocks/MockEventLogStorage.res +19 -2
  36. package/src/test/Mocks/MockEventLogStorage.res.mjs +21 -2
  37. package/tests/PluginEventDecodeTest.res +51 -0
  38. package/tests/PluginEventDecodeTest.res.mjs +63 -0
  39. package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
  40. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
  41. package/tests/adapter/DcbEventLogStorageTest.res +46 -0
  42. package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
  43. package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
  44. package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
  45. package/tests/adapter/EventLogStorageSqliteTest.res +128 -0
  46. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +189 -0
  47. package/tests/adapter/ProjectionCheckpointTest.res +422 -0
  48. package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
  49. package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
  50. package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
  51. package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
  52. package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
  53. package/tests/adapter/QueryDbListResolverTest.res +76 -0
  54. package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
  55. package/tests/components/aggregate/AggregateFixtures.res +4 -0
  56. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
  57. package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
  58. package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
  59. package/tests/plugin/PluginBehavior_GWT.res.mjs +1 -0
@@ -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,6 +12,10 @@ 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([])
@@ -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;
@@ -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,7 +109,34 @@ 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
142
  // The deliberate seq_nr check above throws Failure("conflict"); a lost race
@@ -104,9 +155,16 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
104
155
  }
105
156
  }
106
157
 
107
- 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> =>
108
162
  selectByIdStmt
109
- ->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
+ ])
110
168
  ->Array.map(decodePayload)
111
169
 
112
170
  let replay: EventLog.replay<string, JSON.t> = async id => replayArray(id)
@@ -114,25 +172,73 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
114
172
  // node:sqlite's iterate() could back a lazy stream, but the current Stream
115
173
  // API only has array-based fromIterable. Small events, dev-only backend →
116
174
  // materialise via all() and wrap. Swap to a lazy adapter if it matters.
117
- let replayStream: string => Stream.t<JSON.t, string, unit> = id =>
118
- replayArray(id)->Stream.fromIterable
119
-
120
- let appendStream: EventLog.appendStream<string, JSON.t> = (startingSeqNr, id, stream) => {
121
- let seqNrRef = ref(startingSeqNr)
122
- stream->Stream.runForEach(json => {
123
- let currentSeq = seqNrRef.contents
124
- 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 =>
125
234
  switch result {
126
- | Ok() =>
127
- seqNrRef := currentSeq + 1
128
- Effect.succeed()
235
+ | Ok() => Effect.succeed()
129
236
  // appendStream's error channel is a string; map the typed append error.
130
237
  | Error(ReventlessCore.EventLog.Conflict) => Effect.fail("conflict")
131
238
  | Error(StorageFailure(msg)) => Effect.fail(msg)
132
239
  }
133
240
  )
134
- })
135
- }
241
+ )
136
242
 
137
243
  (
138
244
  name,
@@ -144,6 +250,8 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
144
250
  replay,
145
251
  replayStream,
146
252
  appendStream,
253
+ latestSnapshot,
254
+ writeSnapshot,
147
255
  }),
148
256
  },
149
257
  )
@@ -1,15 +1,20 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
3
6
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
8
  import * as Effect from "effect/Effect";
6
- import * as Stream from "effect/Stream";
9
+ import * as Stream$1 from "effect/Stream";
7
10
  import * as Pulumi from "@pulumi/pulumi";
8
11
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
9
12
  import * as SqliteDriver$ReventlessLocal from "../SqliteDriver.res.mjs";
13
+ import * as ProjectionPending$ReventlessLocal from "../ProjectionPending.res.mjs";
10
14
 
11
15
  function ensureSchema(db) {
12
16
  SqliteDriver$ReventlessLocal.exec(db, "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))");
17
+ SqliteDriver$ReventlessLocal.exec(db, "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))");
13
18
  }
14
19
 
15
20
  function decodePayload(row) {
@@ -28,7 +33,7 @@ function decodePayload(row) {
28
33
  }
29
34
 
30
35
  function countAll(db) {
31
- SqliteDriver$ReventlessLocal.exec(db, "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))");
36
+ ensureSchema(db);
32
37
  let row = SqliteDriver$ReventlessLocal.get(SqliteDriver$ReventlessLocal.prepare(db, "SELECT COUNT(*) AS c FROM event_log"), []);
33
38
  if (row === undefined) {
34
39
  return 0;
@@ -42,12 +47,15 @@ function countAll(db) {
42
47
  }
43
48
 
44
49
  function makeStorage(db, name, param) {
45
- SqliteDriver$ReventlessLocal.exec(db, "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))");
50
+ ensureSchema(db);
46
51
  let insertStmt = SqliteDriver$ReventlessLocal.prepare(db, "INSERT INTO event_log(log_name, aggregate_id, seq_nr, payload) VALUES(?,?,?,?)");
47
- let countStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT COUNT(*) AS c FROM event_log WHERE log_name=? AND aggregate_id=?");
48
- let selectByIdStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT payload FROM event_log WHERE log_name=? AND aggregate_id=? ORDER BY seq_nr ASC");
49
- let currentCount = id => {
50
- let row = SqliteDriver$ReventlessLocal.get(countStmt, [
52
+ let nextSeqStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT COALESCE(MAX(seq_nr), -1) + 1 AS c FROM event_log WHERE log_name=? AND aggregate_id=?");
53
+ let selectByIdStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT payload FROM event_log WHERE log_name=? AND aggregate_id=? AND seq_nr >= ? ORDER BY seq_nr ASC");
54
+ let lastRowidStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT last_insert_rowid() AS r");
55
+ let upsertSnapshotStmt = SqliteDriver$ReventlessLocal.prepare(db, "INSERT OR REPLACE INTO snapshot(log_name, aggregate_id, seq_nr, state, schema_hash) VALUES(?,?,?,?,?)");
56
+ let selectSnapshotStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT seq_nr, state, schema_hash FROM snapshot WHERE log_name=? AND aggregate_id=?");
57
+ let expectedNextSeq = id => {
58
+ let row = SqliteDriver$ReventlessLocal.get(nextSeqStmt, [
51
59
  name,
52
60
  id
53
61
  ]);
@@ -61,10 +69,10 @@ function makeStorage(db, name, param) {
61
69
  return 0;
62
70
  }
63
71
  };
64
- let append = async (seqNr, id, jsons) => {
72
+ let appendTracked = async (track, seqNr, id, jsons) => {
65
73
  try {
66
- SqliteDriver$ReventlessLocal.transaction(db, () => {
67
- let existing = currentCount(id);
74
+ let lastRowid = SqliteDriver$ReventlessLocal.transaction(db, () => {
75
+ let existing = expectedNextSeq(id);
68
76
  if (seqNr !== existing) {
69
77
  throw {
70
78
  RE_EXN_ID: "Failure",
@@ -78,7 +86,24 @@ function makeStorage(db, name, param) {
78
86
  seqNr + i | 0,
79
87
  JSON.stringify(json)
80
88
  ]));
89
+ let row = SqliteDriver$ReventlessLocal.get(lastRowidStmt, []);
90
+ if (row === undefined) {
91
+ return 0;
92
+ }
93
+ let match = row["r"];
94
+ if (typeof match === "number") {
95
+ return match | 0;
96
+ } else {
97
+ return 0;
98
+ }
81
99
  });
100
+ if (track && lastRowid > 0) {
101
+ let count = jsons.length;
102
+ ProjectionPending$ReventlessLocal.trackAppended("Aggregate", Stdlib_Array.filterMap(jsons.map((json, i) => Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d["msgId"]), Stdlib_JSON.Decode.string), msgId => [
103
+ msgId,
104
+ ((lastRowid - count | 0) + 1 | 0) + i | 0
105
+ ])), x => x));
106
+ }
82
107
  return {
83
108
  TAG: "Ok",
84
109
  _0: undefined
@@ -119,32 +144,87 @@ function makeStorage(db, name, param) {
119
144
  }
120
145
  }
121
146
  };
122
- let replayArray = id => SqliteDriver$ReventlessLocal.all(selectByIdStmt, [
123
- name,
124
- id
125
- ]).map(decodePayload);
126
- let replay = replayArray;
127
- let replayStream = id => Stream.fromIterable(replayArray(id));
128
- let appendStream = (startingSeqNr, id, stream) => {
129
- let seqNrRef = {
130
- contents: startingSeqNr
131
- };
132
- return Stream.runForEach(stream, json => {
133
- let currentSeq = seqNrRef.contents;
134
- return Effect.flatMap(Effect.promise(() => append(currentSeq, id, [json])), result => {
135
- if (result.TAG === "Ok") {
136
- seqNrRef.contents = currentSeq + 1 | 0;
137
- return Effect.succeed();
138
- }
139
- let msg = result._0;
140
- if (typeof msg !== "object") {
141
- return Effect.fail("conflict");
142
- } else {
143
- return Effect.fail(msg._0);
144
- }
145
- });
146
- });
147
+ let append = (seqNr, id, jsons) => appendTracked(true, seqNr, id, jsons);
148
+ let replayArray = (id, fromSeqOpt) => {
149
+ let fromSeq = fromSeqOpt !== undefined ? fromSeqOpt : 0;
150
+ return SqliteDriver$ReventlessLocal.all(selectByIdStmt, [
151
+ name,
152
+ id,
153
+ fromSeq
154
+ ]).map(decodePayload);
155
+ };
156
+ let replay = async id => replayArray(id, undefined);
157
+ let replayStream = (id, fromSeq) => Stream$1.fromIterable(replayArray(id, fromSeq));
158
+ let latestSnapshot = async id => {
159
+ try {
160
+ let row = SqliteDriver$ReventlessLocal.get(selectSnapshotStmt, [
161
+ name,
162
+ id
163
+ ]);
164
+ if (row === undefined) {
165
+ return {
166
+ TAG: "Ok",
167
+ _0: undefined
168
+ };
169
+ }
170
+ let match = row["seq_nr"];
171
+ let match$1 = row["state"];
172
+ let match$2 = row["schema_hash"];
173
+ if (typeof match === "number" && typeof match$1 === "string" && typeof match$2 === "string") {
174
+ return {
175
+ TAG: "Ok",
176
+ _0: {
177
+ seqNr: match | 0,
178
+ state: JSON.parse(match$1),
179
+ schemaHash: match$2
180
+ }
181
+ };
182
+ } else {
183
+ return {
184
+ TAG: "Error",
185
+ _0: "snapshot row has unexpected shape"
186
+ };
187
+ }
188
+ } catch (raw_exn) {
189
+ let exn = Primitive_exceptions.internalToException(raw_exn);
190
+ return {
191
+ TAG: "Error",
192
+ _0: Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "snapshot read error")
193
+ };
194
+ }
147
195
  };
196
+ let writeSnapshot = async (id, snap) => {
197
+ try {
198
+ SqliteDriver$ReventlessLocal.run(upsertSnapshotStmt, [
199
+ name,
200
+ id,
201
+ snap.seqNr,
202
+ JSON.stringify(snap.state),
203
+ snap.schemaHash
204
+ ]);
205
+ return {
206
+ TAG: "Ok",
207
+ _0: undefined
208
+ };
209
+ } catch (raw_exn) {
210
+ let exn = Primitive_exceptions.internalToException(raw_exn);
211
+ return {
212
+ TAG: "Error",
213
+ _0: Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "snapshot write error")
214
+ };
215
+ }
216
+ };
217
+ let appendStream = (startingSeqNr, id, stream) => Effect.flatMap(Stream.runCollect(stream), jsons => Effect.flatMap(Effect.promise(() => appendTracked(false, startingSeqNr, id, jsons)), result => {
218
+ if (result.TAG === "Ok") {
219
+ return Effect.succeed();
220
+ }
221
+ let msg = result._0;
222
+ if (typeof msg !== "object") {
223
+ return Effect.fail("conflict");
224
+ } else {
225
+ return Effect.fail(msg._0);
226
+ }
227
+ }));
148
228
  return [
149
229
  name,
150
230
  replay,
@@ -154,7 +234,9 @@ function makeStorage(db, name, param) {
154
234
  append: append,
155
235
  replay: replay,
156
236
  replayStream: replayStream,
157
- appendStream: appendStream
237
+ appendStream: appendStream,
238
+ latestSnapshot: latestSnapshot,
239
+ writeSnapshot: writeSnapshot
158
240
  })
159
241
  }
160
242
  ];
@@ -180,4 +262,4 @@ export {
180
262
  makeStorage,
181
263
  Make,
182
264
  }
183
- /* Stdlib_JsExn Not a pure module */
265
+ /* Stream Not a pure module */