@reventlessdev/reventless-local 3.0.0-alpha.114 → 3.0.0-alpha.116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +116 -67
  4. package/src/Platform.res.mjs +114 -190
  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 +123 -40
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +96 -24
  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 +24 -3
  12. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +21 -4
  13. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +148 -27
  14. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +144 -34
  15. package/src/adapter/LocalBus.res +85 -3
  16. package/src/adapter/LocalBus.res.mjs +220 -66
  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 +128 -10
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +123 -23
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +187 -12
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +120 -7
  29. package/src/adapter/SqliteDriver.res +17 -2
  30. package/src/adapter/SqliteDriver.res.mjs +10 -2
  31. package/src/components/DcbEventLog_Builder.res +7 -1
  32. package/src/components/DcbEventLog_Builder.res.mjs +3 -3
  33. package/src/components/ReadModel_Builder.res +3 -1
  34. package/src/components/ReadModel_Builder.res.mjs +13 -4
  35. package/src/components/StateViewSlice_Builder.res +4 -1
  36. package/src/components/StateViewSlice_Builder.res.mjs +9 -3
  37. package/src/test/Mocks/MockEventLogStorage.res +20 -3
  38. package/src/test/Mocks/MockEventLogStorage.res.mjs +25 -3
  39. package/tests/PluginEventDecodeTest.res +51 -0
  40. package/tests/PluginEventDecodeTest.res.mjs +63 -0
  41. package/tests/adapter/BackendParityTest.res +64 -0
  42. package/tests/adapter/BackendParityTest.res.mjs +45 -0
  43. package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
  44. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
  45. package/tests/adapter/DcbEventLogStorageTest.res +46 -0
  46. package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
  47. package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
  48. package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
  49. package/tests/adapter/EventLogStorageSqliteTest.res +132 -2
  50. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +195 -1
  51. package/tests/adapter/LocalBusPubSubTest.res +31 -0
  52. package/tests/adapter/LocalBusPubSubTest.res.mjs +28 -0
  53. package/tests/adapter/ProjectionCheckpointTest.res +422 -0
  54. package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
  55. package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
  56. package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
  57. package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
  58. package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
  59. package/tests/adapter/QueryDbListResolverTest.res +76 -0
  60. package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
  61. package/tests/components/aggregate/AggregateFixtures.res +4 -0
  62. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
  63. package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
  64. package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
  65. 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,10 +376,20 @@ 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)
309
- | _ => Error("conflict")
383
+ | exn =>
384
+ // The deliberate append-condition failure above throws
385
+ // Failure("conflict: …"); a lost race on the computed position shows up here
386
+ // as a PRIMARY KEY / UNIQUE violation — also a genuine conflict. Every other
387
+ // failure (disk full, SQL error, locked db) must surface as a real error,
388
+ // not the retryable "conflict" sentinel that would be retried forever.
389
+ let msg = exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("")
390
+ msg->String.includes("constraint failed")
391
+ ? Error("conflict")
392
+ : Error(msg == "" ? "storage error" : msg)
310
393
  }
311
394
  }
312
395
 
@@ -2,20 +2,24 @@
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Stream from "effect/Stream";
8
9
  import * as Pulumi from "@pulumi/pulumi";
9
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";
10
12
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
11
13
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
12
14
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
13
15
  import * as SqliteDriver$ReventlessLocal from "../SqliteDriver.res.mjs";
16
+ import * as ProjectionPending$ReventlessLocal from "../ProjectionPending.res.mjs";
14
17
 
15
18
  function ensureSchema(db) {
16
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))");
17
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)");
18
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)");
19
23
  }
20
24
 
21
25
  function posToInt(pos) {
@@ -95,7 +99,7 @@ function buildClauseSql(logName, clause, after) {
95
99
  ];
96
100
  }
97
101
 
98
- function buildQuerySql(logName, query, after) {
102
+ function buildQueryWhere(logName, query, after) {
99
103
  if (query.length === 0) {
100
104
  let baseWhere = after !== undefined ? "log_name = ? AND position > ?" : "log_name = ?";
101
105
  let baseParams = after !== undefined ? [
@@ -103,7 +107,7 @@ function buildQuerySql(logName, query, after) {
103
107
  after
104
108
  ] : [logName];
105
109
  return [
106
- `SELECT position, event_type, data, meta, recorded_at FROM dcb_event WHERE ` + baseWhere + ` ORDER BY position ASC`,
110
+ baseWhere,
107
111
  baseParams
108
112
  ];
109
113
  }
@@ -111,18 +115,36 @@ function buildQuerySql(logName, query, after) {
111
115
  let combinedWhere = clauses.map(param => param[0]).join(" OR ");
112
116
  let combinedParams = clauses.flatMap(param => param[1]);
113
117
  return [
114
- `SELECT position, event_type, data, meta, recorded_at FROM dcb_event WHERE ` + combinedWhere + ` ORDER BY position ASC`,
118
+ combinedWhere,
115
119
  combinedParams
116
120
  ];
117
121
  }
118
122
 
119
- 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) {
120
132
  ensureSchema(db);
121
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");
122
135
  let insertTagStmt = SqliteDriver$ReventlessLocal.prepare(db, "INSERT INTO dcb_tag(log_name, position, tag_key, tag_value) VALUES(?,?,?,?)");
123
136
  let maxPositionStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT COALESCE(MAX(position), 0) AS m FROM dcb_event WHERE log_name = ?");
124
137
  SqliteDriver$ReventlessLocal.prepare(db, "SELECT MAX(position) AS m FROM dcb_event WHERE log_name = ?");
125
- 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
+ };
126
148
  let currentMaxPosition = () => {
127
149
  let row = SqliteDriver$ReventlessLocal.get(maxPositionStmt, [name]);
128
150
  if (row === undefined) {
@@ -135,23 +157,50 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
135
157
  return 0;
136
158
  }
137
159
  };
138
- let tagsForPosition = pos => SqliteDriver$ReventlessLocal.all(tagsForPositionsStmt, [
139
- name,
140
- pos
141
- ]).map(row => {
142
- let match = row["tag_key"];
143
- let key = typeof match === "string" ? match : "";
144
- let match$1 = row["tag_value"];
145
- let value = typeof match$1 === "string" ? match$1 : "";
146
- return {
147
- key: key,
148
- value: value
149
- };
150
- });
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
+ };
151
191
  let runQuery = (query, after) => {
152
192
  let match = buildQuerySql(name, query, after);
153
- let stmt = SqliteDriver$ReventlessLocal.prepare(db, match[0]);
154
- 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 => {
155
204
  let v = row["position"];
156
205
  let position = v !== undefined ? coercePosition(v) : 0;
157
206
  let match = row["event_type"];
@@ -181,12 +230,16 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
181
230
  position: position.toString(),
182
231
  eventType: eventType,
183
232
  data: data,
184
- tags: tagsForPosition(position),
233
+ tags: Stdlib_Option.getOr(tagsByPos[position.toString()], []),
185
234
  meta: meta,
186
235
  recordedAt: recordedAt
187
236
  };
188
237
  });
189
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
+ };
190
243
  let read = async (query, after) => {
191
244
  let afterInt = Stdlib_Option.map(after, posToInt);
192
245
  let events = runQuery(query, afterInt);
@@ -209,12 +262,12 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
209
262
  _0: ""
210
263
  }
211
264
  };
265
+ let appended = [];
212
266
  try {
213
267
  SqliteDriver$ReventlessLocal.transaction(db, () => {
214
268
  if (condition !== undefined) {
215
269
  let afterInt = Stdlib_Option.map(condition.after, posToInt);
216
- let conflicting = runQuery(condition.query, afterInt);
217
- if (conflicting.length !== 0) {
270
+ if (anyMatch(condition.query, afterInt)) {
218
271
  throw {
219
272
  RE_EXN_ID: "Failure",
220
273
  _1: "conflict: condition check failed",
@@ -241,6 +294,16 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
241
294
  JSON.stringify(metaJson),
242
295
  recordedAt
243
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
+ }
244
307
  event.tags.forEach(tag => SqliteDriver$ReventlessLocal.run(insertTagStmt, [
245
308
  name,
246
309
  pos,
@@ -254,6 +317,7 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
254
317
  _0: lastPos.contents.toString()
255
318
  };
256
319
  });
320
+ ProjectionPending$ReventlessLocal.trackAppended("Dcb", appended);
257
321
  return result.contents;
258
322
  } catch (raw_msg) {
259
323
  let msg = Primitive_exceptions.internalToException(raw_msg);
@@ -262,11 +326,18 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
262
326
  TAG: "Error",
263
327
  _0: msg._1
264
328
  };
265
- } else {
329
+ }
330
+ let msg$1 = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(msg), Stdlib_JsExn.message), "");
331
+ if (msg$1.includes("constraint failed")) {
266
332
  return {
267
333
  TAG: "Error",
268
334
  _0: "conflict"
269
335
  };
336
+ } else {
337
+ return {
338
+ TAG: "Error",
339
+ _0: msg$1 === "" ? "storage error" : msg$1
340
+ };
270
341
  }
271
342
  }
272
343
  };
@@ -293,6 +364,7 @@ export {
293
364
  coercePosition,
294
365
  sqlEscape,
295
366
  buildClauseSql,
367
+ buildQueryWhere,
296
368
  buildQuerySql,
297
369
  makeStorage,
298
370
  }