@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.
- package/CHANGELOG.md +19 -0
- package/package.json +6 -6
- package/src/Platform.res +91 -17
- package/src/Platform.res.mjs +89 -30
- 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 +113 -39
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +87 -23
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +23 -2
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +20 -3
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +132 -24
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +119 -37
- package/src/adapter/LocalBus.res +62 -0
- package/src/adapter/LocalBus.res.mjs +120 -0
- 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 +32 -6
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +30 -14
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +163 -11
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +101 -3
- package/src/adapter/SqliteDriver.res +11 -1
- package/src/adapter/SqliteDriver.res.mjs +5 -1
- 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 +19 -2
- package/src/test/Mocks/MockEventLogStorage.res.mjs +21 -2
- package/tests/PluginEventDecodeTest.res +51 -0
- package/tests/PluginEventDecodeTest.res.mjs +63 -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 +128 -0
- package/tests/adapter/EventLogStorageSqliteTest.res.mjs +189 -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
|
@@ -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
|
-
|
|
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;
|
|
@@ -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,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
|
|
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([
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
50
|
-
|
|
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
|
|
72
|
+
let appendTracked = async (track, seqNr, id, jsons) => {
|
|
65
73
|
try {
|
|
66
|
-
SqliteDriver$ReventlessLocal.transaction(db, () => {
|
|
67
|
-
let existing =
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
/*
|
|
265
|
+
/* Stream Not a pure module */
|