@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
@@ -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 read = async (query, after) => {
49
- let filtered = events.contents.filter(event => {
50
- let afterMatch = after !== undefined ? posToInt(event.position) > posToInt(after) : true;
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(event, query);
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 ? events.contents.some(event => {
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 storedEvents = newEvents.map(event => {
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
- return {
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: event.eventType,
85
- data: event.data,
86
- tags: event.tags,
87
- meta: event.meta,
88
- recordedAt: Message$ReventlessCore.nowAsISOString()
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), (param, param$1) => {}, (param, param$1) => {}, name, indexes, partitionTag, opts);
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
- let buildQuerySql = (
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
- let _ = bus
179
- let _ = publishToTopic
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
- let tagsForPosition = (pos: int): array<DcbTag.tag> =>
192
- tagsForPositionsStmt
193
- ->SqliteDriver.all([JSON.Encode.string(name), JSON.Encode.int(pos)])
194
- ->Array.map(row => {
195
- let key = switch row->Dict.get("tag_key") {
196
- | Some(JSON.String(s)) => s
197
- | _ => ""
198
- }
199
- let value = switch row->Dict.get("tag_value") {
200
- | Some(JSON.String(s)) => s
201
- | _ => ""
202
- }
203
- ({key, value}: DcbTag.tag)
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 stmt = db->SqliteDriver.prepare(sql)
209
- stmt
210
- ->SqliteDriver.all(params)
211
- ->Array.map(row => {
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: tagsForPosition(position),
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
- let conflicting = runQuery(~query=cond.query, ~after=afterInt)
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 buildQuerySql(logName, query, after) {
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
- `SELECT position, event_type, data, meta, recorded_at FROM dcb_event WHERE ` + baseWhere + ` ORDER BY position ASC`,
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
- `SELECT position, event_type, data, meta, recorded_at FROM dcb_event WHERE ` + combinedWhere + ` ORDER BY position ASC`,
118
+ combinedWhere,
116
119
  combinedParams
117
120
  ];
118
121
  }
119
122
 
120
- function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
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 tagsForPositionsStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT position, tag_key, tag_value FROM dcb_tag WHERE log_name = ? AND position = ?");
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 tagsForPosition = pos => SqliteDriver$ReventlessLocal.all(tagsForPositionsStmt, [
140
- name,
141
- pos
142
- ]).map(row => {
143
- let match = row["tag_key"];
144
- let key = typeof match === "string" ? match : "";
145
- let match$1 = row["tag_value"];
146
- let value = typeof match$1 === "string" ? match$1 : "";
147
- return {
148
- key: key,
149
- value: value
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 stmt = SqliteDriver$ReventlessLocal.prepare(db, match[0]);
155
- return SqliteDriver$ReventlessLocal.all(stmt, match[1]).map(row => {
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: tagsForPosition(position),
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
- let conflicting = runQuery(condition.query, afterInt);
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
+ }