@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.
- package/CHANGELOG.md +30 -0
- package/package.json +8 -8
- package/src/Platform.res +116 -67
- package/src/Platform.res.mjs +114 -190
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +102 -17
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +105 -22
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +123 -40
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +96 -24
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +24 -3
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +21 -4
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +148 -27
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +144 -34
- package/src/adapter/LocalBus.res +85 -3
- package/src/adapter/LocalBus.res.mjs +220 -66
- package/src/adapter/ProjectionCheckpoint.res +350 -0
- package/src/adapter/ProjectionCheckpoint.res.mjs +327 -0
- package/src/adapter/ProjectionPending.res +60 -0
- package/src/adapter/ProjectionPending.res.mjs +64 -0
- package/src/adapter/QueryDb/QueryDbListQuery.res +261 -0
- package/src/adapter/QueryDb/QueryDbListQuery.res.mjs +212 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +54 -286
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +28 -190
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +128 -10
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +123 -23
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +187 -12
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +120 -7
- package/src/adapter/SqliteDriver.res +17 -2
- package/src/adapter/SqliteDriver.res.mjs +10 -2
- package/src/components/DcbEventLog_Builder.res +7 -1
- package/src/components/DcbEventLog_Builder.res.mjs +3 -3
- package/src/components/ReadModel_Builder.res +3 -1
- package/src/components/ReadModel_Builder.res.mjs +13 -4
- package/src/components/StateViewSlice_Builder.res +4 -1
- package/src/components/StateViewSlice_Builder.res.mjs +9 -3
- package/src/test/Mocks/MockEventLogStorage.res +20 -3
- package/src/test/Mocks/MockEventLogStorage.res.mjs +25 -3
- package/tests/PluginEventDecodeTest.res +51 -0
- package/tests/PluginEventDecodeTest.res.mjs +63 -0
- package/tests/adapter/BackendParityTest.res +64 -0
- package/tests/adapter/BackendParityTest.res.mjs +45 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
- package/tests/adapter/DcbEventLogStorageTest.res +46 -0
- package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
- package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
- package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
- package/tests/adapter/EventLogStorageSqliteTest.res +132 -2
- package/tests/adapter/EventLogStorageSqliteTest.res.mjs +195 -1
- package/tests/adapter/LocalBusPubSubTest.res +31 -0
- package/tests/adapter/LocalBusPubSubTest.res.mjs +28 -0
- package/tests/adapter/ProjectionCheckpointTest.res +422 -0
- package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
- package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
- package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
- package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
- package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
- package/tests/adapter/QueryDbListResolverTest.res +76 -0
- package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
- package/tests/components/aggregate/AggregateFixtures.res +4 -0
- package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
- package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
- package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
- 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(
|
|
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
|
-
|
|
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 =>
|
|
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: "
|
|
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 =
|
|
39
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
64
|
-
switch
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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([
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
)
|