@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.
- package/CHANGELOG.md +30 -0
- package/package.json +8 -8
- package/src/Platform.res +116 -67
- package/src/Platform.res.mjs +114 -190
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +102 -17
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +105 -22
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +123 -40
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +96 -24
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +24 -3
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +21 -4
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +148 -27
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +144 -34
- package/src/adapter/LocalBus.res +85 -3
- package/src/adapter/LocalBus.res.mjs +220 -66
- package/src/adapter/ProjectionCheckpoint.res +350 -0
- package/src/adapter/ProjectionCheckpoint.res.mjs +327 -0
- package/src/adapter/ProjectionPending.res +60 -0
- package/src/adapter/ProjectionPending.res.mjs +64 -0
- package/src/adapter/QueryDb/QueryDbListQuery.res +261 -0
- package/src/adapter/QueryDb/QueryDbListQuery.res.mjs +212 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +54 -286
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +28 -190
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +128 -10
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +123 -23
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +187 -12
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +120 -7
- package/src/adapter/SqliteDriver.res +17 -2
- package/src/adapter/SqliteDriver.res.mjs +10 -2
- package/src/components/DcbEventLog_Builder.res +7 -1
- package/src/components/DcbEventLog_Builder.res.mjs +3 -3
- package/src/components/ReadModel_Builder.res +3 -1
- package/src/components/ReadModel_Builder.res.mjs +13 -4
- package/src/components/StateViewSlice_Builder.res +4 -1
- package/src/components/StateViewSlice_Builder.res.mjs +9 -3
- package/src/test/Mocks/MockEventLogStorage.res +20 -3
- package/src/test/Mocks/MockEventLogStorage.res.mjs +25 -3
- package/tests/PluginEventDecodeTest.res +51 -0
- package/tests/PluginEventDecodeTest.res.mjs +63 -0
- package/tests/adapter/BackendParityTest.res +64 -0
- package/tests/adapter/BackendParityTest.res.mjs +45 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
- package/tests/adapter/DcbEventLogStorageTest.res +46 -0
- package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
- package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
- package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
- package/tests/adapter/EventLogStorageSqliteTest.res +132 -2
- package/tests/adapter/EventLogStorageSqliteTest.res.mjs +195 -1
- package/tests/adapter/LocalBusPubSubTest.res +31 -0
- package/tests/adapter/LocalBusPubSubTest.res.mjs +28 -0
- package/tests/adapter/ProjectionCheckpointTest.res +422 -0
- package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
- package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
- package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
- package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
- package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
- package/tests/adapter/QueryDbListResolverTest.res +76 -0
- package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
- package/tests/components/aggregate/AggregateFixtures.res +4 -0
- package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
- package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
- package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
- package/tests/plugin/PluginBehavior_GWT.res.mjs +1 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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, () =>
|
|
219
|
-
|
|
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
|
|
77
|
+
let snapshot = {
|
|
74
78
|
contents: []
|
|
75
79
|
};
|
|
76
|
-
let
|
|
77
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
196
|
+
let subMap = getOrCreateSubMap(id);
|
|
125
197
|
subMap[subKey] = state;
|
|
198
|
+
recordExpiry(id, subKey, param[2]);
|
|
126
199
|
});
|
|
127
|
-
|
|
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 (
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
235
|
+
dirty.contents = true;
|
|
146
236
|
if (hadIt) {
|
|
147
237
|
publishRemoved(id, subValue);
|
|
148
238
|
}
|
|
149
239
|
} else {
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, () =>
|
|
213
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
->
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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> = (
|