@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
@@ -69,6 +69,8 @@ module Make = (Bus: LocalBus.T) => {
69
69
  registerQueryDb: Bus.registerQueryDb,
70
70
  registerQueryDbScan: Bus.registerQueryDbScan,
71
71
  registerQueryDbStream: Bus.registerQueryDbStream,
72
+ registerQueryDbIndexLookup: Bus.registerQueryDbIndexLookup,
73
+ registerQueryDbListPage: Bus.registerQueryDbListPage,
72
74
  }
73
75
 
74
76
  let makeMemory: QueryDb_Adapter.storageMaker<unit, unit> = (
@@ -81,10 +83,74 @@ module Make = (Bus: LocalBus.T) => {
81
83
  ~opts as _,
82
84
  ) => {
83
85
  let store: ref<dict<dict<JSON.t>>> = ref(Dict.make())
84
- let allItems: ref<array<JSON.t>> = ref([])
86
+ // Lazy flattened snapshot for the registered scan/stream closures. The old
87
+ // shape reflattened+resorted the *entire* store on every single-row write
88
+ // (`syncAll`), so a projection replaying N events was O(n²). Instead each
89
+ // mutation just marks the snapshot dirty; the scan closure reflattens once,
90
+ // on demand, and only when something changed since the last read.
91
+ let snapshot: ref<array<JSON.t>> = ref([])
92
+ let dirty = ref(false)
93
+ let syncAll = () => dirty := true
94
+ let currentItems = () => {
95
+ if dirty.contents {
96
+ snapshot.contents = flattenStore(store.contents)
97
+ dirty := false
98
+ }
99
+ snapshot.contents
100
+ }
85
101
 
86
- let syncAll = () => {
87
- allItems.contents = flattenStore(store.contents)
102
+ // TTL parity with the SQLite backend (which filters on `expires_at`). We
103
+ // record an absolute expiry (epoch seconds, the same value SQLite stores) per
104
+ // (partition, sub-key) and drop expired entries lazily on read — a session
105
+ // without reads never spends cycles expiring, matching SQLite's read-time
106
+ // `notExpiredClause`. Previously `~ttl` was ignored entirely in memory.
107
+ let expiries: ref<dict<dict<float>>> = ref(Dict.make())
108
+ let nowSecs = () => Date.now() /. 1000.0
109
+ let recordExpiry = (id: string, subKey: string, ttl: option<int>) =>
110
+ switch ttl {
111
+ | Some(t) =>
112
+ let subExp = switch expiries.contents->Dict.get(id) {
113
+ | Some(m) => m
114
+ | None =>
115
+ let m = Dict.make()
116
+ expiries.contents->Dict.set(id, m)
117
+ m
118
+ }
119
+ subExp->Dict.set(subKey, Int.toFloat(t))
120
+ | None =>
121
+ switch expiries.contents->Dict.get(id) {
122
+ | Some(m) => m->Dict.delete(subKey)
123
+ | None => ()
124
+ }
125
+ }
126
+ let purgeExpired = () => {
127
+ let now = nowSecs()
128
+ let removedAny = ref(false)
129
+ expiries.contents
130
+ ->Dict.toArray
131
+ ->Array.forEach(((id, subExp)) =>
132
+ subExp
133
+ ->Dict.toArray
134
+ ->Array.forEach(((subKey, exp)) =>
135
+ if exp <= now {
136
+ switch store.contents->Dict.get(id) {
137
+ | Some(sm) =>
138
+ if sm->Dict.get(subKey)->Option.isSome {
139
+ sm->Dict.delete(subKey)
140
+ removedAny := true
141
+ }
142
+ if sm->Dict.keysToArray->Array.length == 0 {
143
+ store.contents->Dict.delete(id)
144
+ }
145
+ | None => ()
146
+ }
147
+ subExp->Dict.delete(subKey)
148
+ }
149
+ )
150
+ )
151
+ if removedAny.contents {
152
+ syncAll()
153
+ }
88
154
  }
89
155
 
90
156
  let getOrCreateSubMap = (partitionKey: string): dict<JSON.t> =>
@@ -96,11 +162,15 @@ module Make = (Bus: LocalBus.T) => {
96
162
  m
97
163
  }
98
164
 
99
- let load: QueryDb.load<string, JSON.t> = async id =>
165
+ let load: QueryDb.load<string, JSON.t> = async id => {
166
+ purgeExpired()
100
167
  Ok(sortedItems(store.contents, id))
168
+ }
101
169
 
102
- let loadStream: QueryDb.loadStream<string, JSON.t> = id =>
170
+ let loadStream: QueryDb.loadStream<string, JSON.t> = id => {
171
+ purgeExpired()
103
172
  sortedItems(store.contents, id)->Stream.fromIterable
173
+ }
104
174
 
105
175
  // Entity key matches the AWS StateTopic Lambda output (Phase 1):
106
176
  // single-key tables → partition value; composite tables → `pk-sk`.
@@ -129,20 +199,22 @@ module Make = (Bus: LocalBus.T) => {
129
199
  Bus.publishStateChange(~name, ~descriptor)
130
200
  }
131
201
 
132
- let save: QueryDb.save<string, JSON.t> = async (id, state, _saveMode, _ttl) => {
202
+ let save: QueryDb.save<string, JSON.t> = async (id, state, _saveMode, ttl) => {
133
203
  let subKey = getSubKey(state, subIdField)
134
204
  let subMap = getOrCreateSubMap(id)
135
205
  subMap->Dict.set(subKey, state)
206
+ recordExpiry(id, subKey, ttl)
136
207
  syncAll()
137
208
  publishUpdated(id, state)
138
209
  Ok()
139
210
  }
140
211
 
141
212
  let saveBatch: QueryDb.saveBatch<string, JSON.t> = async batch => {
142
- batch->Array.forEach(((id, state, _ttl)) => {
213
+ batch->Array.forEach(((id, state, ttl)) => {
143
214
  let subKey = getSubKey(state, subIdField)
144
215
  let subMap = getOrCreateSubMap(id)
145
216
  subMap->Dict.set(subKey, state)
217
+ recordExpiry(id, subKey, ttl)
146
218
  })
147
219
  syncAll()
148
220
  // Notify subscribers for each saved item
@@ -150,7 +222,33 @@ module Make = (Bus: LocalBus.T) => {
150
222
  Ok()
151
223
  }
152
224
 
153
- let count: QueryDb.count<string> = async (_id, _fieldName, inc) => Ok(inc)
225
+ // Atomic-ish counter, mirroring DynamoDB's `ADD #fieldName :inc` on key {id}:
226
+ // read the field on the partition-key item (counters are single-state, so the
227
+ // implicit sub-key ""), add `inc`, persist, and return the NEW total. The
228
+ // previous `Ok(inc)` echoed the increment and never touched the store, so the
229
+ // running total was wrong and `loadStream` never reflected the counter.
230
+ let count: QueryDb.count<string> = async (id, fieldName, inc) => {
231
+ let subMap = getOrCreateSubMap(id)
232
+ let existing = subMap->Dict.get("")->Option.flatMap(JSON.Decode.object)
233
+ let current =
234
+ existing
235
+ ->Option.flatMap(o => o->Dict.get(fieldName))
236
+ ->Option.flatMap(JSON.Decode.float)
237
+ ->Option.mapOr(0, Float.toInt)
238
+ let next = current + inc
239
+ let obj = Dict.make()
240
+ switch existing {
241
+ | Some(o) => o->Dict.toArray->Array.forEach(((k, v)) => obj->Dict.set(k, v))
242
+ | None => ()
243
+ }
244
+ obj->Dict.set("id", JSON.Encode.string(id))
245
+ obj->Dict.set(fieldName, JSON.Encode.int(next))
246
+ let newItem = JSON.Encode.object(obj)
247
+ subMap->Dict.set("", newItem)
248
+ syncAll()
249
+ publishUpdated(id, newItem)
250
+ Ok(next)
251
+ }
154
252
 
155
253
  let delete: QueryDb.delete<string> = async (id, subIdOpt) => {
156
254
  switch subIdOpt {
@@ -215,8 +313,28 @@ module Make = (Bus: LocalBus.T) => {
215
313
  }
216
314
 
217
315
  Bus.registerQueryDb(name, ops)
218
- Bus.registerQueryDbScan(name, () => allItems.contents)
219
- Bus.registerQueryDbStream(name, () => allItems.contents->Stream.fromIterable)
316
+ Bus.registerQueryDbScan(name, () => {
317
+ purgeExpired()
318
+ currentItems()
319
+ })
320
+ Bus.registerQueryDbStream(name, () => {
321
+ purgeExpired()
322
+ currentItems()->Stream.fromIterable
323
+ })
324
+ // Equality lookup on an indexed field. No real in-memory index (dev scale),
325
+ // but reusing the lazy snapshot avoids reflattening per call and keeps result
326
+ // parity with the SQLite `json_extract` push-down (both match string fields).
327
+ Bus.registerQueryDbIndexLookup(name, (field, value) => {
328
+ purgeExpired()
329
+ currentItems()->Array.filter(item =>
330
+ item
331
+ ->JSON.Decode.object
332
+ ->Option.flatMap(d => d->Dict.get(field))
333
+ ->Option.flatMap(JSON.Decode.string)
334
+ ->Option.map(v => v == value)
335
+ ->Option.getOr(false)
336
+ )
337
+ })
220
338
 
221
339
  {
222
340
  resources: [],
@@ -60,21 +60,85 @@ function Make(Bus) {
60
60
  let sqliteBusCallbacks_registerQueryDb = Bus.registerQueryDb;
61
61
  let sqliteBusCallbacks_registerQueryDbScan = Bus.registerQueryDbScan;
62
62
  let sqliteBusCallbacks_registerQueryDbStream = Bus.registerQueryDbStream;
63
+ let sqliteBusCallbacks_registerQueryDbIndexLookup = Bus.registerQueryDbIndexLookup;
64
+ let sqliteBusCallbacks_registerQueryDbListPage = Bus.registerQueryDbListPage;
63
65
  let sqliteBusCallbacks = {
64
66
  publishStateChange: sqliteBusCallbacks_publishStateChange,
65
67
  registerQueryDb: sqliteBusCallbacks_registerQueryDb,
66
68
  registerQueryDbScan: sqliteBusCallbacks_registerQueryDbScan,
67
- registerQueryDbStream: sqliteBusCallbacks_registerQueryDbStream
69
+ registerQueryDbStream: sqliteBusCallbacks_registerQueryDbStream,
70
+ registerQueryDbIndexLookup: sqliteBusCallbacks_registerQueryDbIndexLookup,
71
+ registerQueryDbListPage: sqliteBusCallbacks_registerQueryDbListPage
68
72
  };
69
73
  let makeMemory = (name, param, subIdField, param$1, param$2, param$3, param$4) => {
70
74
  let store = {
71
75
  contents: {}
72
76
  };
73
- let allItems = {
77
+ let snapshot = {
74
78
  contents: []
75
79
  };
76
- let syncAll = () => {
77
- allItems.contents = flattenStore(store.contents);
80
+ let dirty = {
81
+ contents: false
82
+ };
83
+ let currentItems = () => {
84
+ if (dirty.contents) {
85
+ snapshot.contents = flattenStore(store.contents);
86
+ dirty.contents = false;
87
+ }
88
+ return snapshot.contents;
89
+ };
90
+ let expiries = {
91
+ contents: {}
92
+ };
93
+ let recordExpiry = (id, subKey, ttl) => {
94
+ if (ttl !== undefined) {
95
+ let m = expiries.contents[id];
96
+ let subExp;
97
+ if (m !== undefined) {
98
+ subExp = m;
99
+ } else {
100
+ let m$1 = {};
101
+ expiries.contents[id] = m$1;
102
+ subExp = m$1;
103
+ }
104
+ subExp[subKey] = ttl;
105
+ return;
106
+ }
107
+ let m$2 = expiries.contents[id];
108
+ if (m$2 !== undefined) {
109
+ return Stdlib_Dict.$$delete(m$2, subKey);
110
+ }
111
+ };
112
+ let purgeExpired = () => {
113
+ let now = Date.now() / 1000.0;
114
+ let removedAny = {
115
+ contents: false
116
+ };
117
+ Object.entries(expiries.contents).forEach(param => {
118
+ let subExp = param[1];
119
+ let id = param[0];
120
+ Object.entries(subExp).forEach(param => {
121
+ if (param[1] > now) {
122
+ return;
123
+ }
124
+ let subKey = param[0];
125
+ let sm = store.contents[id];
126
+ if (sm !== undefined) {
127
+ if (Stdlib_Option.isSome(sm[subKey])) {
128
+ Stdlib_Dict.$$delete(sm, subKey);
129
+ removedAny.contents = true;
130
+ }
131
+ if (Object.keys(sm).length === 0) {
132
+ Stdlib_Dict.$$delete(store.contents, id);
133
+ }
134
+ }
135
+ Stdlib_Dict.$$delete(subExp, subKey);
136
+ });
137
+ });
138
+ if (removedAny.contents) {
139
+ dirty.contents = true;
140
+ return;
141
+ }
78
142
  };
79
143
  let getOrCreateSubMap = partitionKey => {
80
144
  let m = store.contents[partitionKey];
@@ -85,11 +149,17 @@ function Make(Bus) {
85
149
  store.contents[partitionKey] = m$1;
86
150
  return m$1;
87
151
  };
88
- let load = async id => ({
89
- TAG: "Ok",
90
- _0: sortedItems(store.contents, id)
91
- });
92
- let loadStream = id => Stream.fromIterable(sortedItems(store.contents, id));
152
+ let load = async id => {
153
+ purgeExpired();
154
+ return {
155
+ TAG: "Ok",
156
+ _0: sortedItems(store.contents, id)
157
+ };
158
+ };
159
+ let loadStream = id => {
160
+ purgeExpired();
161
+ return Stream.fromIterable(sortedItems(store.contents, id));
162
+ };
93
163
  let entityKeyFor = (id, subKey) => {
94
164
  if (subIdField !== undefined) {
95
165
  return id + "-" + subKey;
@@ -106,11 +176,12 @@ function Make(Bus) {
106
176
  let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor("Removed", entityKeyFor(id, subKey), undefined);
107
177
  Bus.publishStateChange(name, descriptor);
108
178
  };
109
- let save = async (id, state, _saveMode, _ttl) => {
179
+ let save = async (id, state, _saveMode, ttl) => {
110
180
  let subKey = getSubKey(state, subIdField);
111
181
  let subMap = getOrCreateSubMap(id);
112
182
  subMap[subKey] = state;
113
- syncAll();
183
+ recordExpiry(id, subKey, ttl);
184
+ dirty.contents = true;
114
185
  publishUpdated(id, state);
115
186
  return {
116
187
  TAG: "Ok",
@@ -120,21 +191,40 @@ function Make(Bus) {
120
191
  let saveBatch = async batch => {
121
192
  batch.forEach(param => {
122
193
  let state = param[1];
194
+ let id = param[0];
123
195
  let subKey = getSubKey(state, subIdField);
124
- let subMap = getOrCreateSubMap(param[0]);
196
+ let subMap = getOrCreateSubMap(id);
125
197
  subMap[subKey] = state;
198
+ recordExpiry(id, subKey, param[2]);
126
199
  });
127
- syncAll();
200
+ dirty.contents = true;
128
201
  batch.forEach(param => publishUpdated(param[0], param[1]));
129
202
  return {
130
203
  TAG: "Ok",
131
204
  _0: undefined
132
205
  };
133
206
  };
134
- let count = async (_id, _fieldName, inc) => ({
135
- TAG: "Ok",
136
- _0: inc
137
- });
207
+ let count = async (id, fieldName, inc) => {
208
+ let subMap = getOrCreateSubMap(id);
209
+ let existing = Stdlib_Option.flatMap(subMap[""], Stdlib_JSON.Decode.object);
210
+ let current = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(existing, o => o[fieldName]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
211
+ let next = current + inc | 0;
212
+ let obj = {};
213
+ if (existing !== undefined) {
214
+ Object.entries(existing).forEach(param => {
215
+ obj[param[0]] = param[1];
216
+ });
217
+ }
218
+ obj["id"] = id;
219
+ obj[fieldName] = next;
220
+ subMap[""] = obj;
221
+ dirty.contents = true;
222
+ publishUpdated(id, obj);
223
+ return {
224
+ TAG: "Ok",
225
+ _0: next
226
+ };
227
+ };
138
228
  let $$delete = async (id, subIdOpt) => {
139
229
  if (subIdOpt !== undefined) {
140
230
  let subValue = subIdOpt[1];
@@ -142,18 +232,18 @@ function Make(Bus) {
142
232
  if (subMap !== undefined) {
143
233
  let hadIt = Stdlib_Option.isSome(subMap[subValue]);
144
234
  Stdlib_Dict.$$delete(subMap, subValue);
145
- syncAll();
235
+ dirty.contents = true;
146
236
  if (hadIt) {
147
237
  publishRemoved(id, subValue);
148
238
  }
149
239
  } else {
150
- syncAll();
240
+ dirty.contents = true;
151
241
  }
152
242
  } else {
153
243
  let subMap$1 = store.contents[id];
154
244
  let subKeys = subMap$1 !== undefined ? Object.keys(subMap$1) : [];
155
245
  Stdlib_Dict.$$delete(store.contents, id);
156
- syncAll();
246
+ dirty.contents = true;
157
247
  subKeys.forEach(subKey => publishRemoved(id, subKey));
158
248
  }
159
249
  return {
@@ -192,7 +282,7 @@ function Make(Bus) {
192
282
  }
193
283
  Stdlib_Dict.$$delete(store.contents, id);
194
284
  });
195
- syncAll();
285
+ dirty.contents = true;
196
286
  removed.forEach(param => publishRemoved(param[0], param[1]));
197
287
  return {
198
288
  TAG: "Ok",
@@ -209,8 +299,18 @@ function Make(Bus) {
209
299
  deleteBatch: deleteBatch
210
300
  };
211
301
  Bus.registerQueryDb(name, ops);
212
- Bus.registerQueryDbScan(name, () => allItems.contents);
213
- Bus.registerQueryDbStream(name, () => Stream.fromIterable(allItems.contents));
302
+ Bus.registerQueryDbScan(name, () => {
303
+ purgeExpired();
304
+ return currentItems();
305
+ });
306
+ Bus.registerQueryDbStream(name, () => {
307
+ purgeExpired();
308
+ return Stream.fromIterable(currentItems());
309
+ });
310
+ Bus.registerQueryDbIndexLookup(name, (field, value) => {
311
+ purgeExpired();
312
+ return currentItems().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]), Stdlib_JSON.Decode.string), v => v === value), false));
313
+ });
214
314
  return {
215
315
  resources: [],
216
316
  dataSourceName: Pulumi.output(""),
@@ -139,6 +139,15 @@ type busCallbacks = {
139
139
  registerQueryDb: (string, QueryDb_Adapter.operations) => unit,
140
140
  registerQueryDbScan: (string, unit => array<JSON.t>) => unit,
141
141
  registerQueryDbStream: (string, unit => Stream.t<JSON.t, string, unit>) => unit,
142
+ registerQueryDbIndexLookup: (string, (string, string) => array<JSON.t>) => unit,
143
+ registerQueryDbListPage: (
144
+ string,
145
+ (
146
+ ~argsDict: dict<JSON.t>,
147
+ ~capability: GraphQL_FragmentGenerator.serverCapability,
148
+ ~labelField: string,
149
+ ) => option<JSON.t>,
150
+ ) => unit,
142
151
  }
143
152
 
144
153
  // SQL fragment that excludes expired rows. Uses unix-epoch seconds via
@@ -178,17 +187,38 @@ let makeStorage = (
178
187
  ->SqliteDriver.all([JSON.Encode.string(id)])
179
188
  ->Array.map(decodeItem)
180
189
 
181
- let allRows = (): array<JSON.t> =>
182
- scanAllStmt
183
- ->SqliteDriver.all([])
184
- ->Array.map(row => {
185
- let item = decodeItem(row)
186
- let partition = switch row->Dict.get("partition_key") {
187
- | Some(JSON.String(s)) => s
188
- | _ => ""
189
- }
190
- withId(item, partition)
191
- })
190
+ let rowToItem = (row: dict<JSON.t>): JSON.t => {
191
+ let item = decodeItem(row)
192
+ let partition = switch row->Dict.get("partition_key") {
193
+ | Some(JSON.String(s)) => s
194
+ | _ => ""
195
+ }
196
+ withId(item, partition)
197
+ }
198
+
199
+ let allRows = (): array<JSON.t> => scanAllStmt->SqliteDriver.all([])->Array.map(rowToItem)
200
+
201
+ // Equality lookup on an indexed JSON field, pushed down to SQLite so the query
202
+ // rides the `json_extract` GSI index (`ensureIndexes`) instead of scanning +
203
+ // parsing every row in the resolver. Statements are prepared once per field and
204
+ // cached. String comparison mirrors the resolver's JS path (which only matched
205
+ // string-typed fields), so results are identical.
206
+ let indexLookupStmts: Dict.t<SqliteDriver.statement> = Dict.make()
207
+ let lookupByField = (field: string, value: string): array<JSON.t> => {
208
+ let stmt = switch indexLookupStmts->Dict.get(field) {
209
+ | Some(s) => s
210
+ | None =>
211
+ // Escape single quotes in the JSON path literal so an odd field name can't
212
+ // break out of the string (bound `?` already covers the value).
213
+ let escapedField = field->String.replaceAll("'", "''")
214
+ let s = db->SqliteDriver.prepare(
215
+ `SELECT partition_key, sub_key, item FROM ${table} WHERE json_extract(item, '$.${escapedField}') = ? AND ${notExpiredClause}`,
216
+ )
217
+ indexLookupStmts->Dict.set(field, s)
218
+ s
219
+ }
220
+ stmt->SqliteDriver.all([JSON.Encode.string(value)])->Array.map(rowToItem)
221
+ }
192
222
 
193
223
  let saveOne = (id: string, state: JSON.t, ttl: option<int>) => {
194
224
  let subKey = computeSubKey(state, subIdField)
@@ -260,7 +290,30 @@ let makeStorage = (
260
290
  Ok()
261
291
  }
262
292
 
263
- let count: QueryDb.count<string> = async (_id, _fieldName, inc) => Ok(inc)
293
+ // Mirror DynamoDB's `ADD #fieldName :inc` on key {id}: read the counter field
294
+ // on the partition-key item (counters are single-state, sub_key=""), add `inc`,
295
+ // upsert, and return the NEW total. The previous `Ok(inc)` echoed the increment
296
+ // and never persisted, so the total was wrong and `loadStream` never saw it.
297
+ let count: QueryDb.count<string> = async (id, fieldName, inc) => {
298
+ let existing = rowsFor(id)->Array.get(0)->Option.flatMap(JSON.Decode.object)
299
+ let current =
300
+ existing
301
+ ->Option.flatMap(o => o->Dict.get(fieldName))
302
+ ->Option.flatMap(JSON.Decode.float)
303
+ ->Option.mapOr(0, Float.toInt)
304
+ let next = current + inc
305
+ let obj = Dict.make()
306
+ switch existing {
307
+ | Some(o) => o->Dict.toArray->Array.forEach(((k, v)) => obj->Dict.set(k, v))
308
+ | None => ()
309
+ }
310
+ obj->Dict.set("id", JSON.Encode.string(id))
311
+ obj->Dict.set(fieldName, JSON.Encode.int(next))
312
+ let newItem = JSON.Encode.object(obj)
313
+ saveOne(id, newItem, None)
314
+ publishUpdated(id, newItem)
315
+ Ok(next)
316
+ }
264
317
 
265
318
  let deleteOne = (id: string, subIdOpt) =>
266
319
  switch subIdOpt {
@@ -308,6 +361,126 @@ let makeStorage = (
308
361
  bus.registerQueryDb(name, ops)
309
362
  bus.registerQueryDbScan(name, () => allRows())
310
363
  bus.registerQueryDbStream(name, () => allRows()->Stream.fromIterable)
364
+ bus.registerQueryDbIndexLookup(name, lookupByField)
365
+
366
+ // Connection-list push-down. Builds `json_extract` predicates + `ORDER BY …
367
+ // LIMIT pageSize+1` so a page fetch reads only what it returns rather than the
368
+ // whole read model. Reproduces `QueryDbListQuery` semantics exactly for the
369
+ // shapes it handles; returns None (→ the resolver falls back to that shared
370
+ // spec over a full scan) for shapes not bit-exact in SQL: case-insensitive
371
+ // search/searchPrefix, Relay-global-id `ids`, and backward pagination
372
+ // (last/before). Cursor values, edges, and pageInfo are built by the SAME
373
+ // `QueryDbListQuery` helpers the fallback uses, so only the WHERE/ORDER/LIMIT
374
+ // needs to match — which the parity harness asserts across a data + args
375
+ // matrix. Comparisons use TEXT (CAST) to mirror the JS string-comparison
376
+ // semantics; BINARY collation agrees with JS `<` for the ASCII id/status/name
377
+ // fields used as sort/filter keys.
378
+ let listStmtCache: Dict.t<SqliteDriver.statement> = Dict.make()
379
+ let preparedList = (sql: string): SqliteDriver.statement =>
380
+ switch listStmtCache->Dict.get(sql) {
381
+ | Some(s) => s
382
+ | None =>
383
+ let s = db->SqliteDriver.prepare(sql)
384
+ listStmtCache->Dict.set(sql, s)
385
+ s
386
+ }
387
+ let jsonText = (field: string): string =>
388
+ `CAST(json_extract(item, '$.${field->String.replaceAll("'", "''")}') AS TEXT)`
389
+ let idExpr = "COALESCE(json_extract(item, '$.id'), partition_key)"
390
+ let listPage = (
391
+ ~argsDict: dict<JSON.t>,
392
+ ~capability: GraphQL_FragmentGenerator.serverCapability,
393
+ ~labelField as _,
394
+ ): option<JSON.t> => {
395
+ let filterDict =
396
+ argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
397
+ let strNonEmpty = k =>
398
+ filterDict->Dict.get(k)->Option.flatMap(JSON.Decode.string)->Option.mapOr(false, s => s->String.length > 0)
399
+ let hasIds =
400
+ filterDict->Dict.get("ids")->Option.flatMap(JSON.Decode.array)->Option.mapOr(false, a => a->Array.length > 0)
401
+ let last = argsDict->Dict.get("last")->Option.flatMap(JSON.Decode.float)
402
+ let before = argsDict->Dict.get("before")->Option.flatMap(JSON.Decode.string)
403
+ if strNonEmpty("search") || strNonEmpty("searchPrefix") || hasIds || last->Option.isSome || before->Option.isSome {
404
+ None
405
+ } else {
406
+ let whereParts = [notExpiredClause]
407
+ let params = []
408
+ let valString = v =>
409
+ switch v->JSON.Decode.string {
410
+ | Some(s) => Some(s)
411
+ | None => v->JSON.Decode.float->Option.map(f => Float.toString(f))
412
+ }
413
+ capability.filterFields->Array.forEach(f => {
414
+ switch filterDict->Dict.get(f.name ++ "Eq") {
415
+ | Some(v) when v != JSON.Encode.null =>
416
+ whereParts->Array.push(`${jsonText(f.name)} = ?`)
417
+ params->Array.push(JSON.Encode.string(valString(v)->Option.getOr("")))
418
+ | _ => ()
419
+ }
420
+ if f.range {
421
+ switch filterDict->Dict.get(f.name ++ "From") {
422
+ | Some(v) when v != JSON.Encode.null =>
423
+ whereParts->Array.push(`${jsonText(f.name)} >= ?`)
424
+ params->Array.push(JSON.Encode.string(valString(v)->Option.getOr("")))
425
+ | _ => ()
426
+ }
427
+ switch filterDict->Dict.get(f.name ++ "To") {
428
+ | Some(v) when v != JSON.Encode.null =>
429
+ whereParts->Array.push(`${jsonText(f.name)} <= ?`)
430
+ params->Array.push(JSON.Encode.string(valString(v)->Option.getOr("")))
431
+ | _ => ()
432
+ }
433
+ }
434
+ })
435
+ let orderByDict = argsDict->Dict.get("orderBy")->Option.flatMap(JSON.Decode.object)
436
+ let orderByField =
437
+ orderByDict->Option.flatMap(ob => ob->Dict.get("field"))->Option.flatMap(JSON.Decode.string)
438
+ let isDesc =
439
+ orderByDict
440
+ ->Option.flatMap(ob => ob->Dict.get("direction"))
441
+ ->Option.flatMap(JSON.Decode.string)
442
+ ->Option.getOr("ASC") == "DESC"
443
+ let cursorExpr = switch orderByField {
444
+ | Some(f) => jsonText(f)
445
+ | None => idExpr
446
+ }
447
+ switch argsDict->Dict.get("after")->Option.flatMap(JSON.Decode.string) {
448
+ | Some(c) =>
449
+ whereParts->Array.push(`${cursorExpr} ${isDesc ? "<" : ">"} ?`)
450
+ params->Array.push(JSON.Encode.string(QueryDbListQuery.decodeCursor(c)))
451
+ | None => ()
452
+ }
453
+ let orderClause = switch orderByField {
454
+ | Some(f) => `${jsonText(f)} ${isDesc ? "DESC" : "ASC"}, ${idExpr} ASC`
455
+ | None => `${idExpr} ASC`
456
+ }
457
+ let pageSize =
458
+ argsDict
459
+ ->Dict.get("first")
460
+ ->Option.flatMap(JSON.Decode.float)
461
+ ->Option.map(Float.toInt)
462
+ ->Option.getOr(QueryDbListQuery.defaultListPageSize)
463
+ let sql = `SELECT partition_key, item FROM ${table} WHERE ${whereParts->Array.join(" AND ")} ORDER BY ${orderClause} LIMIT ?`
464
+ let rows =
465
+ preparedList(sql)
466
+ ->SqliteDriver.all(params->Array.concat([JSON.Encode.int(pageSize + 1)]))
467
+ ->Array.map(rowToItem)
468
+ let hasMore = rows->Array.length > pageSize
469
+ let pageItems = rows->Array.slice(~start=0, ~end=pageSize)
470
+ let cursorField = orderByField->Option.getOr("id")
471
+ let cursorValueOf = item =>
472
+ QueryDbListQuery.getFieldString(item, cursorField)->Option.getOr(QueryDbListQuery.getId(item))
473
+ Some(
474
+ QueryDbListQuery.buildConnection(
475
+ ~pageItems,
476
+ ~hasNextPage=hasMore,
477
+ ~hasPreviousPage=false,
478
+ ~cursorValueOf,
479
+ ),
480
+ )
481
+ }
482
+ }
483
+ bus.registerQueryDbListPage(name, listPage)
311
484
 
312
485
  {
313
486
  resources: [],
@@ -325,6 +498,8 @@ module Make = (Bus: LocalBus.T, DbProvider: {let db: SqliteDriver.t}) => {
325
498
  registerQueryDb: Bus.registerQueryDb,
326
499
  registerQueryDbScan: Bus.registerQueryDbScan,
327
500
  registerQueryDbStream: Bus.registerQueryDbStream,
501
+ registerQueryDbIndexLookup: Bus.registerQueryDbIndexLookup,
502
+ registerQueryDbListPage: Bus.registerQueryDbListPage,
328
503
  }
329
504
 
330
505
  let make: QueryDb_Adapter.storageMaker<unit, unit> = (