@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
6
|
import * as Effect from "effect/Effect";
|
|
6
7
|
import * as Stream from "effect/Stream";
|
|
@@ -38,6 +39,30 @@ function matchesQuery(event, query) {
|
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
function tagPostingKey(key, value) {
|
|
43
|
+
return key + "\x00" + value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function intersectSorted(a, b) {
|
|
47
|
+
let out = [];
|
|
48
|
+
let i = 0;
|
|
49
|
+
let j = 0;
|
|
50
|
+
while (i < a.length && j < b.length) {
|
|
51
|
+
let av = a[i];
|
|
52
|
+
let bv = b[j];
|
|
53
|
+
if (av === bv) {
|
|
54
|
+
out.push(av);
|
|
55
|
+
i = i + 1 | 0;
|
|
56
|
+
j = j + 1 | 0;
|
|
57
|
+
} else if (av < bv) {
|
|
58
|
+
i = i + 1 | 0;
|
|
59
|
+
} else {
|
|
60
|
+
j = j + 1 | 0;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
function makeStorage(_name, param, param$1, param$2) {
|
|
42
67
|
let events = {
|
|
43
68
|
contents: []
|
|
@@ -45,15 +70,71 @@ function makeStorage(_name, param, param$1, param$2) {
|
|
|
45
70
|
let position = {
|
|
46
71
|
contents: 0
|
|
47
72
|
};
|
|
48
|
-
let
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
let byTag = {};
|
|
74
|
+
let byType = {};
|
|
75
|
+
let pushInto = (dict, key, idx) => {
|
|
76
|
+
let list = dict[key];
|
|
77
|
+
if (list !== undefined) {
|
|
78
|
+
list.push(idx);
|
|
79
|
+
} else {
|
|
80
|
+
dict[key] = [idx];
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
let indexEvent = (idx, ev) => {
|
|
84
|
+
pushInto(byType, ev.eventType, idx);
|
|
85
|
+
ev.tags.forEach(t => pushInto(byTag, tagPostingKey(t.key, t.value), idx));
|
|
86
|
+
};
|
|
87
|
+
let candidateIndices = query => {
|
|
88
|
+
let acc = {};
|
|
89
|
+
let addAll = arr => {
|
|
90
|
+
arr.forEach(i => {
|
|
91
|
+
acc[i.toString()] = i;
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
query.forEach(clause => {
|
|
95
|
+
let tags = clause.tags;
|
|
96
|
+
if (tags !== undefined && tags.length !== 0) {
|
|
97
|
+
let lists = tags.map(t => Stdlib_Option.getOr(byTag[tagPostingKey(t.key, t.value)], []));
|
|
98
|
+
if (lists.some(l => l.length === 0)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
let sorted = lists.toSorted((a, b) => a.length - b.length | 0);
|
|
102
|
+
let acc0 = sorted[0];
|
|
103
|
+
return addAll(Stdlib_Array.reduce(sorted.slice(1, sorted.length), acc0, intersectSorted));
|
|
104
|
+
}
|
|
105
|
+
let types = clause.eventTypes;
|
|
106
|
+
if (types !== undefined) {
|
|
107
|
+
types.forEach(t => addAll(Stdlib_Option.getOr(byType[t], [])));
|
|
108
|
+
} else {
|
|
109
|
+
events.contents.forEach((param, i) => {
|
|
110
|
+
acc[i.toString()] = i;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return Object.values(acc).toSorted((a, b) => a - b | 0);
|
|
115
|
+
};
|
|
116
|
+
let matchingEvents = (query, after) => {
|
|
117
|
+
let pass = ev => {
|
|
118
|
+
let afterMatch = after !== undefined ? posToInt(ev.position) > posToInt(after) : true;
|
|
51
119
|
if (afterMatch) {
|
|
52
|
-
return matchesQuery(
|
|
120
|
+
return matchesQuery(ev, query);
|
|
53
121
|
} else {
|
|
54
122
|
return false;
|
|
55
123
|
}
|
|
56
|
-
}
|
|
124
|
+
};
|
|
125
|
+
if (query.length === 0) {
|
|
126
|
+
return events.contents.filter(pass);
|
|
127
|
+
} else {
|
|
128
|
+
return Stdlib_Array.filterMap(candidateIndices(query), i => {
|
|
129
|
+
let ev = events.contents[i];
|
|
130
|
+
if (pass(ev)) {
|
|
131
|
+
return ev;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
let read = async (query, after) => {
|
|
137
|
+
let filtered = matchingEvents(query, after);
|
|
57
138
|
let headPosition = Stdlib_Option.map(events.contents[events.contents.length - 1 | 0], e => e.position);
|
|
58
139
|
return {
|
|
59
140
|
events: filtered,
|
|
@@ -61,32 +142,32 @@ function makeStorage(_name, param, param$1, param$2) {
|
|
|
61
142
|
};
|
|
62
143
|
};
|
|
63
144
|
let append = async (newEvents, condition) => {
|
|
64
|
-
let conflictDetected = condition !== undefined ?
|
|
65
|
-
let pos = condition.after;
|
|
66
|
-
let afterMatch = pos !== undefined ? posToInt(event.position) > posToInt(pos) : true;
|
|
67
|
-
if (afterMatch) {
|
|
68
|
-
return matchesQuery(event, condition.query);
|
|
69
|
-
} else {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
}) : false;
|
|
145
|
+
let conflictDetected = condition !== undefined ? matchingEvents(condition.query, condition.after).length !== 0 : false;
|
|
73
146
|
if (conflictDetected) {
|
|
74
147
|
return {
|
|
75
148
|
TAG: "Error",
|
|
76
149
|
_0: "conflict: condition check failed"
|
|
77
150
|
};
|
|
78
151
|
}
|
|
79
|
-
let
|
|
152
|
+
let base = events.contents.length;
|
|
153
|
+
let storedEvents = newEvents.map((event, j) => {
|
|
80
154
|
position.contents = position.contents + 1 | 0;
|
|
81
155
|
let pos = position.contents.toString();
|
|
82
|
-
|
|
156
|
+
let stored_eventType = event.eventType;
|
|
157
|
+
let stored_data = event.data;
|
|
158
|
+
let stored_tags = event.tags;
|
|
159
|
+
let stored_meta = event.meta;
|
|
160
|
+
let stored_recordedAt = Message$ReventlessCore.nowAsISOString();
|
|
161
|
+
let stored = {
|
|
83
162
|
position: pos,
|
|
84
|
-
eventType:
|
|
85
|
-
data:
|
|
86
|
-
tags:
|
|
87
|
-
meta:
|
|
88
|
-
recordedAt:
|
|
163
|
+
eventType: stored_eventType,
|
|
164
|
+
data: stored_data,
|
|
165
|
+
tags: stored_tags,
|
|
166
|
+
meta: stored_meta,
|
|
167
|
+
recordedAt: stored_recordedAt
|
|
89
168
|
};
|
|
169
|
+
indexEvent(base + j | 0, stored);
|
|
170
|
+
return stored;
|
|
90
171
|
});
|
|
91
172
|
events.contents = events.contents.concat(storedEvents);
|
|
92
173
|
return {
|
|
@@ -117,7 +198,7 @@ function Make(Bus) {
|
|
|
117
198
|
let make = (name, indexes, partitionTag, param, opts) => {
|
|
118
199
|
let db = BackendState$ReventlessLocal.getDb();
|
|
119
200
|
if (db !== undefined) {
|
|
120
|
-
let match = DcbEventLogStorage_Sqlite$ReventlessLocal.makeStorage(Primitive_option.valFromOption(db),
|
|
201
|
+
let match = DcbEventLogStorage_Sqlite$ReventlessLocal.makeStorage(Primitive_option.valFromOption(db), name, indexes, partitionTag, opts);
|
|
121
202
|
Bus.registerDcbEventLogRead(match[0], match[1]);
|
|
122
203
|
return match[2];
|
|
123
204
|
}
|
|
@@ -133,6 +214,8 @@ function Make(Bus) {
|
|
|
133
214
|
export {
|
|
134
215
|
posToInt,
|
|
135
216
|
matchesQuery,
|
|
217
|
+
tagPostingKey,
|
|
218
|
+
intersectSorted,
|
|
136
219
|
makeStorage,
|
|
137
220
|
make,
|
|
138
221
|
Make,
|
|
@@ -34,6 +34,12 @@ let ensureSchema = (db: SqliteDriver.t) => {
|
|
|
34
34
|
db->SqliteDriver.exec(
|
|
35
35
|
"CREATE INDEX IF NOT EXISTS dcb_tag_by_kv ON dcb_tag(log_name, tag_key, tag_value, position)",
|
|
36
36
|
)
|
|
37
|
+
// Serves the per-position tag hydration (the `WHERE log_name = ? AND position
|
|
38
|
+
// IN (...)` batch). The `_by_kv` index leads with tag_key/tag_value, so it
|
|
39
|
+
// cannot answer a position lookup — without this the batch scanned dcb_tag.
|
|
40
|
+
db->SqliteDriver.exec(
|
|
41
|
+
"CREATE INDEX IF NOT EXISTS dcb_tag_by_pos ON dcb_tag(log_name, position)",
|
|
42
|
+
)
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
let posToInt = (pos: string) => pos->Int.fromString->Option.getOr(0)
|
|
@@ -117,7 +123,10 @@ let buildClauseSql = (
|
|
|
117
123
|
("(" ++ parts.contents->Array.join(" AND ") ++ ")", params.contents)
|
|
118
124
|
}
|
|
119
125
|
|
|
120
|
-
|
|
126
|
+
// The WHERE expression (+ bound params) for a query — shared by the row-fetching
|
|
127
|
+
// read path and the LIMIT-1 existence check the append-condition uses, so both
|
|
128
|
+
// match the exact same rows.
|
|
129
|
+
let buildQueryWhere = (
|
|
121
130
|
~logName: string,
|
|
122
131
|
~query: DcbTag.query,
|
|
123
132
|
~after: option<int>,
|
|
@@ -132,26 +141,29 @@ let buildQuerySql = (
|
|
|
132
141
|
| None => [JSON.Encode.string(logName)]
|
|
133
142
|
}
|
|
134
143
|
let _ = sqlEscape
|
|
135
|
-
(
|
|
136
|
-
`SELECT position, event_type, data, meta, recorded_at FROM dcb_event WHERE ${baseWhere} ORDER BY position ASC`,
|
|
137
|
-
baseParams,
|
|
138
|
-
)
|
|
144
|
+
(baseWhere, baseParams)
|
|
139
145
|
} else {
|
|
140
146
|
let clauses = query->Array.map(c => buildClauseSql(~logName, ~clause=c, ~after))
|
|
141
|
-
let combinedWhere =
|
|
142
|
-
clauses->Array.map(((sql, _)) => sql)->Array.join(" OR ")
|
|
147
|
+
let combinedWhere = clauses->Array.map(((sql, _)) => sql)->Array.join(" OR ")
|
|
143
148
|
let combinedParams = clauses->Array.flatMap(((_, p)) => p)
|
|
144
|
-
(
|
|
145
|
-
`SELECT position, event_type, data, meta, recorded_at FROM dcb_event WHERE ${combinedWhere} ORDER BY position ASC`,
|
|
146
|
-
combinedParams,
|
|
147
|
-
)
|
|
149
|
+
(combinedWhere, combinedParams)
|
|
148
150
|
}
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
let buildQuerySql = (
|
|
154
|
+
~logName: string,
|
|
155
|
+
~query: DcbTag.query,
|
|
156
|
+
~after: option<int>,
|
|
157
|
+
): (string, array<JSON.t>) => {
|
|
158
|
+
let (where, params) = buildQueryWhere(~logName, ~query, ~after)
|
|
159
|
+
(
|
|
160
|
+
`SELECT position, event_type, data, meta, recorded_at FROM dcb_event WHERE ${where} ORDER BY position ASC`,
|
|
161
|
+
params,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
151
165
|
let makeStorage = (
|
|
152
166
|
~db: SqliteDriver.t,
|
|
153
|
-
~bus: ('a, JSON.t) => unit,
|
|
154
|
-
~publishToTopic: (~topicName: string, ~json: JSON.t) => unit,
|
|
155
167
|
~name: string,
|
|
156
168
|
~indexes as _,
|
|
157
169
|
~partitionTag as _,
|
|
@@ -162,6 +174,7 @@ let makeStorage = (
|
|
|
162
174
|
let insertEventStmt = db->SqliteDriver.prepare(
|
|
163
175
|
"INSERT INTO dcb_event(log_name, position, event_type, data, meta, recorded_at) VALUES(?,?,?,?,?,?)",
|
|
164
176
|
)
|
|
177
|
+
let lastRowidStmt = db->SqliteDriver.prepare("SELECT last_insert_rowid() AS r")
|
|
165
178
|
let insertTagStmt = db->SqliteDriver.prepare(
|
|
166
179
|
"INSERT INTO dcb_tag(log_name, position, tag_key, tag_value) VALUES(?,?,?,?)",
|
|
167
180
|
)
|
|
@@ -171,12 +184,21 @@ let makeStorage = (
|
|
|
171
184
|
let headPositionStmt = db->SqliteDriver.prepare(
|
|
172
185
|
"SELECT MAX(position) AS m FROM dcb_event WHERE log_name = ?",
|
|
173
186
|
)
|
|
174
|
-
let tagsForPositionsStmt = db->SqliteDriver.prepare(
|
|
175
|
-
"SELECT position, tag_key, tag_value FROM dcb_tag WHERE log_name = ? AND position = ?",
|
|
176
|
-
)
|
|
177
187
|
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
// Prepared-statement cache keyed on SQL text. `runQuery`, the batched tag
|
|
189
|
+
// hydration, and the conflict check all build SQL whose shape varies with the
|
|
190
|
+
// clause/position count; preparing per call (the old `runQuery`) recompiled the
|
|
191
|
+
// same handful of shapes on every read. node:sqlite statements are re-runnable
|
|
192
|
+
// with fresh params, so one prepare per distinct shape suffices.
|
|
193
|
+
let stmtCache: Dict.t<SqliteDriver.statement> = Dict.make()
|
|
194
|
+
let preparedFor = (sql: string): SqliteDriver.statement =>
|
|
195
|
+
switch stmtCache->Dict.get(sql) {
|
|
196
|
+
| Some(s) => s
|
|
197
|
+
| None =>
|
|
198
|
+
let s = db->SqliteDriver.prepare(sql)
|
|
199
|
+
stmtCache->Dict.set(sql, s)
|
|
200
|
+
s
|
|
201
|
+
}
|
|
180
202
|
|
|
181
203
|
let currentMaxPosition = (): int =>
|
|
182
204
|
switch maxPositionStmt->SqliteDriver.get([JSON.Encode.string(name)]) {
|
|
@@ -188,27 +210,56 @@ let makeStorage = (
|
|
|
188
210
|
| None => 0
|
|
189
211
|
}
|
|
190
212
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
let
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
(
|
|
204
|
-
|
|
213
|
+
// Hydrate tags for a batch of positions in ONE query, grouped by position, in
|
|
214
|
+
// insertion order (`rowid`) within each position — the same order the old
|
|
215
|
+
// per-position query returned. Replaces the N+1 `tagsForPosition` per event.
|
|
216
|
+
let tagsForPositions = (positions: array<int>): Dict.t<array<DcbTag.tag>> => {
|
|
217
|
+
let byPos: Dict.t<array<DcbTag.tag>> = Dict.make()
|
|
218
|
+
if positions->Array.length > 0 {
|
|
219
|
+
let placeholders = positions->Array.map(_ => "?")->Array.join(",")
|
|
220
|
+
let sql = `SELECT position, tag_key, tag_value FROM dcb_tag WHERE log_name = ? AND position IN (${placeholders}) ORDER BY position ASC, rowid ASC`
|
|
221
|
+
let params =
|
|
222
|
+
[JSON.Encode.string(name)]->Array.concat(positions->Array.map(JSON.Encode.int))
|
|
223
|
+
preparedFor(sql)
|
|
224
|
+
->SqliteDriver.all(params)
|
|
225
|
+
->Array.forEach(row => {
|
|
226
|
+
let pos = switch row->Dict.get("position") {
|
|
227
|
+
| Some(v) => coercePosition(v)
|
|
228
|
+
| None => 0
|
|
229
|
+
}
|
|
230
|
+
let key = switch row->Dict.get("tag_key") {
|
|
231
|
+
| Some(JSON.String(s)) => s
|
|
232
|
+
| _ => ""
|
|
233
|
+
}
|
|
234
|
+
let value = switch row->Dict.get("tag_value") {
|
|
235
|
+
| Some(JSON.String(s)) => s
|
|
236
|
+
| _ => ""
|
|
237
|
+
}
|
|
238
|
+
let k = Int.toString(pos)
|
|
239
|
+
let arr = switch byPos->Dict.get(k) {
|
|
240
|
+
| Some(a) => a
|
|
241
|
+
| None =>
|
|
242
|
+
let a = []
|
|
243
|
+
byPos->Dict.set(k, a)
|
|
244
|
+
a
|
|
245
|
+
}
|
|
246
|
+
arr->Array.push(({key, value}: DcbTag.tag))
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
byPos
|
|
250
|
+
}
|
|
205
251
|
|
|
206
252
|
let runQuery = (~query: DcbTag.query, ~after: option<int>): array<DcbEventLog_Adapter.rawSequencedEvent> => {
|
|
207
253
|
let (sql, params) = buildQuerySql(~logName=name, ~query, ~after)
|
|
208
|
-
let
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
254
|
+
let rows = preparedFor(sql)->SqliteDriver.all(params)
|
|
255
|
+
let positions = rows->Array.map(row =>
|
|
256
|
+
switch row->Dict.get("position") {
|
|
257
|
+
| Some(v) => coercePosition(v)
|
|
258
|
+
| None => 0
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
let tagsByPos = tagsForPositions(positions)
|
|
262
|
+
rows->Array.map(row => {
|
|
212
263
|
let position = switch row->Dict.get("position") {
|
|
213
264
|
| Some(v) => coercePosition(v)
|
|
214
265
|
| None => 0
|
|
@@ -237,13 +288,23 @@ let makeStorage = (
|
|
|
237
288
|
DcbEventLog_Adapter.position: Int.toString(position),
|
|
238
289
|
eventType,
|
|
239
290
|
data,
|
|
240
|
-
tags:
|
|
291
|
+
tags: tagsByPos->Dict.get(Int.toString(position))->Option.getOr([]),
|
|
241
292
|
meta,
|
|
242
293
|
recordedAt,
|
|
243
294
|
}: DcbEventLog_Adapter.rawSequencedEvent)
|
|
244
295
|
})
|
|
245
296
|
}
|
|
246
297
|
|
|
298
|
+
// Does any event match the query? The append-condition only needs existence,
|
|
299
|
+
// so avoid materialising + tag-hydrating every conflicting row (the old
|
|
300
|
+
// `runQuery(...)->Array.length > 0`): a `SELECT 1 … LIMIT 1` short-circuits.
|
|
301
|
+
let anyMatch = (~query: DcbTag.query, ~after: option<int>): bool => {
|
|
302
|
+
let (where, params) = buildQueryWhere(~logName=name, ~query, ~after)
|
|
303
|
+
preparedFor(`SELECT 1 FROM dcb_event WHERE ${where} LIMIT 1`)
|
|
304
|
+
->SqliteDriver.get(params)
|
|
305
|
+
->Option.isSome
|
|
306
|
+
}
|
|
307
|
+
|
|
247
308
|
let read = async (
|
|
248
309
|
~query: DcbTag.query,
|
|
249
310
|
~after=?,
|
|
@@ -263,13 +324,17 @@ let makeStorage = (
|
|
|
263
324
|
~condition=?,
|
|
264
325
|
): result<DcbTag.sequencePosition, string> => {
|
|
265
326
|
let result = ref(Ok(""))
|
|
327
|
+
// (msgId, dcb_event rowid) per inserted event, for the projection
|
|
328
|
+
// checkpoint's DCB-axis pending set. The rowid is read immediately after
|
|
329
|
+
// each event insert — before the tag inserts move last_insert_rowid() to
|
|
330
|
+
// the dcb_tag table. Registered only after the transaction commits.
|
|
331
|
+
let appended: array<(string, int)> = []
|
|
266
332
|
try {
|
|
267
333
|
db->SqliteDriver.transaction(() => {
|
|
268
334
|
switch condition {
|
|
269
335
|
| Some(cond: DcbTag.appendCondition) =>
|
|
270
336
|
let afterInt = cond.after->Option.map(posToInt)
|
|
271
|
-
|
|
272
|
-
if conflicting->Array.length > 0 {
|
|
337
|
+
if anyMatch(~query=cond.query, ~after=afterInt) {
|
|
273
338
|
throw(Failure("conflict: condition check failed"))
|
|
274
339
|
}
|
|
275
340
|
| None => ()
|
|
@@ -290,6 +355,14 @@ let makeStorage = (
|
|
|
290
355
|
JSON.Encode.string(JSON.stringify(metaJson)),
|
|
291
356
|
JSON.Encode.string(recordedAt),
|
|
292
357
|
])
|
|
358
|
+
switch lastRowidStmt->SqliteDriver.get([]) {
|
|
359
|
+
| Some(row) =>
|
|
360
|
+
switch row->Dict.get("r") {
|
|
361
|
+
| Some(JSON.Number(n)) => appended->Array.push((event.meta.msgId, Float.toInt(n)))
|
|
362
|
+
| _ => ()
|
|
363
|
+
}
|
|
364
|
+
| None => ()
|
|
365
|
+
}
|
|
293
366
|
event.tags->Array.forEach(tag => {
|
|
294
367
|
insertTagStmt->SqliteDriver.run([
|
|
295
368
|
JSON.Encode.string(name),
|
|
@@ -303,6 +376,7 @@ let makeStorage = (
|
|
|
303
376
|
|
|
304
377
|
result := Ok(Int.toString(lastPos.contents))
|
|
305
378
|
})
|
|
379
|
+
ProjectionPending.trackAppended(~axis=ProjectionPending.Dcb, appended)
|
|
306
380
|
result.contents
|
|
307
381
|
} catch {
|
|
308
382
|
| Failure(msg) => Error(msg)
|
|
@@ -8,15 +8,18 @@ import * as Effect from "effect/Effect";
|
|
|
8
8
|
import * as Stream from "effect/Stream";
|
|
9
9
|
import * as Pulumi from "@pulumi/pulumi";
|
|
10
10
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
11
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
11
12
|
import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
|
|
12
13
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
13
14
|
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
14
15
|
import * as SqliteDriver$ReventlessLocal from "../SqliteDriver.res.mjs";
|
|
16
|
+
import * as ProjectionPending$ReventlessLocal from "../ProjectionPending.res.mjs";
|
|
15
17
|
|
|
16
18
|
function ensureSchema(db) {
|
|
17
19
|
SqliteDriver$ReventlessLocal.exec(db, "CREATE TABLE IF NOT EXISTS dcb_event (log_name TEXT NOT NULL, position INTEGER NOT NULL, event_type TEXT NOT NULL, data TEXT NOT NULL, meta TEXT NOT NULL, recorded_at TEXT NOT NULL, PRIMARY KEY (log_name, position))");
|
|
18
20
|
SqliteDriver$ReventlessLocal.exec(db, "CREATE TABLE IF NOT EXISTS dcb_tag (log_name TEXT NOT NULL, position INTEGER NOT NULL, tag_key TEXT NOT NULL, tag_value TEXT NOT NULL)");
|
|
19
21
|
SqliteDriver$ReventlessLocal.exec(db, "CREATE INDEX IF NOT EXISTS dcb_tag_by_kv ON dcb_tag(log_name, tag_key, tag_value, position)");
|
|
22
|
+
SqliteDriver$ReventlessLocal.exec(db, "CREATE INDEX IF NOT EXISTS dcb_tag_by_pos ON dcb_tag(log_name, position)");
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
function posToInt(pos) {
|
|
@@ -96,7 +99,7 @@ function buildClauseSql(logName, clause, after) {
|
|
|
96
99
|
];
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
function
|
|
102
|
+
function buildQueryWhere(logName, query, after) {
|
|
100
103
|
if (query.length === 0) {
|
|
101
104
|
let baseWhere = after !== undefined ? "log_name = ? AND position > ?" : "log_name = ?";
|
|
102
105
|
let baseParams = after !== undefined ? [
|
|
@@ -104,7 +107,7 @@ function buildQuerySql(logName, query, after) {
|
|
|
104
107
|
after
|
|
105
108
|
] : [logName];
|
|
106
109
|
return [
|
|
107
|
-
|
|
110
|
+
baseWhere,
|
|
108
111
|
baseParams
|
|
109
112
|
];
|
|
110
113
|
}
|
|
@@ -112,18 +115,36 @@ function buildQuerySql(logName, query, after) {
|
|
|
112
115
|
let combinedWhere = clauses.map(param => param[0]).join(" OR ");
|
|
113
116
|
let combinedParams = clauses.flatMap(param => param[1]);
|
|
114
117
|
return [
|
|
115
|
-
|
|
118
|
+
combinedWhere,
|
|
116
119
|
combinedParams
|
|
117
120
|
];
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
function
|
|
123
|
+
function buildQuerySql(logName, query, after) {
|
|
124
|
+
let match = buildQueryWhere(logName, query, after);
|
|
125
|
+
return [
|
|
126
|
+
`SELECT position, event_type, data, meta, recorded_at FROM dcb_event WHERE ` + match[0] + ` ORDER BY position ASC`,
|
|
127
|
+
match[1]
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function makeStorage(db, name, param, param$1, param$2) {
|
|
121
132
|
ensureSchema(db);
|
|
122
133
|
let insertEventStmt = SqliteDriver$ReventlessLocal.prepare(db, "INSERT INTO dcb_event(log_name, position, event_type, data, meta, recorded_at) VALUES(?,?,?,?,?,?)");
|
|
134
|
+
let lastRowidStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT last_insert_rowid() AS r");
|
|
123
135
|
let insertTagStmt = SqliteDriver$ReventlessLocal.prepare(db, "INSERT INTO dcb_tag(log_name, position, tag_key, tag_value) VALUES(?,?,?,?)");
|
|
124
136
|
let maxPositionStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT COALESCE(MAX(position), 0) AS m FROM dcb_event WHERE log_name = ?");
|
|
125
137
|
SqliteDriver$ReventlessLocal.prepare(db, "SELECT MAX(position) AS m FROM dcb_event WHERE log_name = ?");
|
|
126
|
-
let
|
|
138
|
+
let stmtCache = {};
|
|
139
|
+
let preparedFor = sql => {
|
|
140
|
+
let s = stmtCache[sql];
|
|
141
|
+
if (s !== undefined) {
|
|
142
|
+
return Primitive_option.valFromOption(s);
|
|
143
|
+
}
|
|
144
|
+
let s$1 = SqliteDriver$ReventlessLocal.prepare(db, sql);
|
|
145
|
+
stmtCache[sql] = s$1;
|
|
146
|
+
return s$1;
|
|
147
|
+
};
|
|
127
148
|
let currentMaxPosition = () => {
|
|
128
149
|
let row = SqliteDriver$ReventlessLocal.get(maxPositionStmt, [name]);
|
|
129
150
|
if (row === undefined) {
|
|
@@ -136,23 +157,50 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
|
|
|
136
157
|
return 0;
|
|
137
158
|
}
|
|
138
159
|
};
|
|
139
|
-
let
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
let tagsForPositions = positions => {
|
|
161
|
+
let byPos = {};
|
|
162
|
+
if (positions.length !== 0) {
|
|
163
|
+
let placeholders = positions.map(param => "?").join(",");
|
|
164
|
+
let sql = `SELECT position, tag_key, tag_value FROM dcb_tag WHERE log_name = ? AND position IN (` + placeholders + `) ORDER BY position ASC, rowid ASC`;
|
|
165
|
+
let params = [name].concat(positions.map(prim => prim));
|
|
166
|
+
SqliteDriver$ReventlessLocal.all(preparedFor(sql), params).forEach(row => {
|
|
167
|
+
let v = row["position"];
|
|
168
|
+
let pos = v !== undefined ? coercePosition(v) : 0;
|
|
169
|
+
let match = row["tag_key"];
|
|
170
|
+
let key = typeof match === "string" ? match : "";
|
|
171
|
+
let match$1 = row["tag_value"];
|
|
172
|
+
let value = typeof match$1 === "string" ? match$1 : "";
|
|
173
|
+
let k = pos.toString();
|
|
174
|
+
let a = byPos[k];
|
|
175
|
+
let arr;
|
|
176
|
+
if (a !== undefined) {
|
|
177
|
+
arr = a;
|
|
178
|
+
} else {
|
|
179
|
+
let a$1 = [];
|
|
180
|
+
byPos[k] = a$1;
|
|
181
|
+
arr = a$1;
|
|
182
|
+
}
|
|
183
|
+
arr.push({
|
|
184
|
+
key: key,
|
|
185
|
+
value: value
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return byPos;
|
|
190
|
+
};
|
|
152
191
|
let runQuery = (query, after) => {
|
|
153
192
|
let match = buildQuerySql(name, query, after);
|
|
154
|
-
let
|
|
155
|
-
|
|
193
|
+
let rows = SqliteDriver$ReventlessLocal.all(preparedFor(match[0]), match[1]);
|
|
194
|
+
let positions = rows.map(row => {
|
|
195
|
+
let v = row["position"];
|
|
196
|
+
if (v !== undefined) {
|
|
197
|
+
return coercePosition(v);
|
|
198
|
+
} else {
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
let tagsByPos = tagsForPositions(positions);
|
|
203
|
+
return rows.map(row => {
|
|
156
204
|
let v = row["position"];
|
|
157
205
|
let position = v !== undefined ? coercePosition(v) : 0;
|
|
158
206
|
let match = row["event_type"];
|
|
@@ -182,12 +230,16 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
|
|
|
182
230
|
position: position.toString(),
|
|
183
231
|
eventType: eventType,
|
|
184
232
|
data: data,
|
|
185
|
-
tags:
|
|
233
|
+
tags: Stdlib_Option.getOr(tagsByPos[position.toString()], []),
|
|
186
234
|
meta: meta,
|
|
187
235
|
recordedAt: recordedAt
|
|
188
236
|
};
|
|
189
237
|
});
|
|
190
238
|
};
|
|
239
|
+
let anyMatch = (query, after) => {
|
|
240
|
+
let match = buildQueryWhere(name, query, after);
|
|
241
|
+
return Stdlib_Option.isSome(SqliteDriver$ReventlessLocal.get(preparedFor(`SELECT 1 FROM dcb_event WHERE ` + match[0] + ` LIMIT 1`), match[1]));
|
|
242
|
+
};
|
|
191
243
|
let read = async (query, after) => {
|
|
192
244
|
let afterInt = Stdlib_Option.map(after, posToInt);
|
|
193
245
|
let events = runQuery(query, afterInt);
|
|
@@ -210,12 +262,12 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
|
|
|
210
262
|
_0: ""
|
|
211
263
|
}
|
|
212
264
|
};
|
|
265
|
+
let appended = [];
|
|
213
266
|
try {
|
|
214
267
|
SqliteDriver$ReventlessLocal.transaction(db, () => {
|
|
215
268
|
if (condition !== undefined) {
|
|
216
269
|
let afterInt = Stdlib_Option.map(condition.after, posToInt);
|
|
217
|
-
|
|
218
|
-
if (conflicting.length !== 0) {
|
|
270
|
+
if (anyMatch(condition.query, afterInt)) {
|
|
219
271
|
throw {
|
|
220
272
|
RE_EXN_ID: "Failure",
|
|
221
273
|
_1: "conflict: condition check failed",
|
|
@@ -242,6 +294,16 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
|
|
|
242
294
|
JSON.stringify(metaJson),
|
|
243
295
|
recordedAt
|
|
244
296
|
]);
|
|
297
|
+
let row = SqliteDriver$ReventlessLocal.get(lastRowidStmt, []);
|
|
298
|
+
if (row !== undefined) {
|
|
299
|
+
let match = row["r"];
|
|
300
|
+
if (typeof match === "number") {
|
|
301
|
+
appended.push([
|
|
302
|
+
event.meta.msgId,
|
|
303
|
+
match | 0
|
|
304
|
+
]);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
245
307
|
event.tags.forEach(tag => SqliteDriver$ReventlessLocal.run(insertTagStmt, [
|
|
246
308
|
name,
|
|
247
309
|
pos,
|
|
@@ -255,6 +317,7 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
|
|
|
255
317
|
_0: lastPos.contents.toString()
|
|
256
318
|
};
|
|
257
319
|
});
|
|
320
|
+
ProjectionPending$ReventlessLocal.trackAppended("Dcb", appended);
|
|
258
321
|
return result.contents;
|
|
259
322
|
} catch (raw_msg) {
|
|
260
323
|
let msg = Primitive_exceptions.internalToException(raw_msg);
|
|
@@ -301,6 +364,7 @@ export {
|
|
|
301
364
|
coercePosition,
|
|
302
365
|
sqlEscape,
|
|
303
366
|
buildClauseSql,
|
|
367
|
+
buildQueryWhere,
|
|
304
368
|
buildQuerySql,
|
|
305
369
|
makeStorage,
|
|
306
370
|
}
|
|
@@ -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
|
+
}
|