@reventlessdev/reventless-local 3.0.0-alpha.115 → 3.0.0-alpha.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +6 -6
- package/src/Platform.res +91 -17
- package/src/Platform.res.mjs +89 -30
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +102 -17
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +105 -22
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +113 -39
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +87 -23
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +23 -2
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +20 -3
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +132 -24
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +119 -37
- package/src/adapter/LocalBus.res +62 -0
- package/src/adapter/LocalBus.res.mjs +120 -0
- package/src/adapter/ProjectionCheckpoint.res +350 -0
- package/src/adapter/ProjectionCheckpoint.res.mjs +327 -0
- package/src/adapter/ProjectionPending.res +60 -0
- package/src/adapter/ProjectionPending.res.mjs +64 -0
- package/src/adapter/QueryDb/QueryDbListQuery.res +261 -0
- package/src/adapter/QueryDb/QueryDbListQuery.res.mjs +212 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +54 -286
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +28 -190
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +32 -6
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +30 -14
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +163 -11
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +101 -3
- package/src/adapter/SqliteDriver.res +11 -1
- package/src/adapter/SqliteDriver.res.mjs +5 -1
- package/src/components/ReadModel_Builder.res +3 -1
- package/src/components/ReadModel_Builder.res.mjs +13 -4
- package/src/components/StateViewSlice_Builder.res +4 -1
- package/src/components/StateViewSlice_Builder.res.mjs +9 -3
- package/src/test/Mocks/MockEventLogStorage.res +19 -2
- package/src/test/Mocks/MockEventLogStorage.res.mjs +21 -2
- package/tests/PluginEventDecodeTest.res +51 -0
- package/tests/PluginEventDecodeTest.res.mjs +63 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
- package/tests/adapter/DcbEventLogStorageTest.res +46 -0
- package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
- package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
- package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
- package/tests/adapter/EventLogStorageSqliteTest.res +128 -0
- package/tests/adapter/EventLogStorageSqliteTest.res.mjs +189 -0
- package/tests/adapter/ProjectionCheckpointTest.res +422 -0
- package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
- package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
- package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
- package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
- package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
- package/tests/adapter/QueryDbListResolverTest.res +76 -0
- package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
- package/tests/components/aggregate/AggregateFixtures.res +4 -0
- package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
- package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
- package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
- package/tests/plugin/PluginBehavior_GWT.res.mjs +1 -0
|
@@ -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,20 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
81
83
|
~opts as _,
|
|
82
84
|
) => {
|
|
83
85
|
let store: ref<dict<dict<JSON.t>>> = ref(Dict.make())
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
// TTL parity with the SQLite backend (which filters on `expires_at`). We
|
|
@@ -303,11 +315,25 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
303
315
|
Bus.registerQueryDb(name, ops)
|
|
304
316
|
Bus.registerQueryDbScan(name, () => {
|
|
305
317
|
purgeExpired()
|
|
306
|
-
|
|
318
|
+
currentItems()
|
|
307
319
|
})
|
|
308
320
|
Bus.registerQueryDbStream(name, () => {
|
|
309
321
|
purgeExpired()
|
|
310
|
-
|
|
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
|
+
)
|
|
311
337
|
})
|
|
312
338
|
|
|
313
339
|
{
|
|
@@ -60,21 +60,32 @@ 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;
|
|
78
89
|
};
|
|
79
90
|
let expiries = {
|
|
80
91
|
contents: {}
|
|
@@ -125,7 +136,8 @@ function Make(Bus) {
|
|
|
125
136
|
});
|
|
126
137
|
});
|
|
127
138
|
if (removedAny.contents) {
|
|
128
|
-
|
|
139
|
+
dirty.contents = true;
|
|
140
|
+
return;
|
|
129
141
|
}
|
|
130
142
|
};
|
|
131
143
|
let getOrCreateSubMap = partitionKey => {
|
|
@@ -169,7 +181,7 @@ function Make(Bus) {
|
|
|
169
181
|
let subMap = getOrCreateSubMap(id);
|
|
170
182
|
subMap[subKey] = state;
|
|
171
183
|
recordExpiry(id, subKey, ttl);
|
|
172
|
-
|
|
184
|
+
dirty.contents = true;
|
|
173
185
|
publishUpdated(id, state);
|
|
174
186
|
return {
|
|
175
187
|
TAG: "Ok",
|
|
@@ -185,7 +197,7 @@ function Make(Bus) {
|
|
|
185
197
|
subMap[subKey] = state;
|
|
186
198
|
recordExpiry(id, subKey, param[2]);
|
|
187
199
|
});
|
|
188
|
-
|
|
200
|
+
dirty.contents = true;
|
|
189
201
|
batch.forEach(param => publishUpdated(param[0], param[1]));
|
|
190
202
|
return {
|
|
191
203
|
TAG: "Ok",
|
|
@@ -206,7 +218,7 @@ function Make(Bus) {
|
|
|
206
218
|
obj["id"] = id;
|
|
207
219
|
obj[fieldName] = next;
|
|
208
220
|
subMap[""] = obj;
|
|
209
|
-
|
|
221
|
+
dirty.contents = true;
|
|
210
222
|
publishUpdated(id, obj);
|
|
211
223
|
return {
|
|
212
224
|
TAG: "Ok",
|
|
@@ -220,18 +232,18 @@ function Make(Bus) {
|
|
|
220
232
|
if (subMap !== undefined) {
|
|
221
233
|
let hadIt = Stdlib_Option.isSome(subMap[subValue]);
|
|
222
234
|
Stdlib_Dict.$$delete(subMap, subValue);
|
|
223
|
-
|
|
235
|
+
dirty.contents = true;
|
|
224
236
|
if (hadIt) {
|
|
225
237
|
publishRemoved(id, subValue);
|
|
226
238
|
}
|
|
227
239
|
} else {
|
|
228
|
-
|
|
240
|
+
dirty.contents = true;
|
|
229
241
|
}
|
|
230
242
|
} else {
|
|
231
243
|
let subMap$1 = store.contents[id];
|
|
232
244
|
let subKeys = subMap$1 !== undefined ? Object.keys(subMap$1) : [];
|
|
233
245
|
Stdlib_Dict.$$delete(store.contents, id);
|
|
234
|
-
|
|
246
|
+
dirty.contents = true;
|
|
235
247
|
subKeys.forEach(subKey => publishRemoved(id, subKey));
|
|
236
248
|
}
|
|
237
249
|
return {
|
|
@@ -270,7 +282,7 @@ function Make(Bus) {
|
|
|
270
282
|
}
|
|
271
283
|
Stdlib_Dict.$$delete(store.contents, id);
|
|
272
284
|
});
|
|
273
|
-
|
|
285
|
+
dirty.contents = true;
|
|
274
286
|
removed.forEach(param => publishRemoved(param[0], param[1]));
|
|
275
287
|
return {
|
|
276
288
|
TAG: "Ok",
|
|
@@ -289,11 +301,15 @@ function Make(Bus) {
|
|
|
289
301
|
Bus.registerQueryDb(name, ops);
|
|
290
302
|
Bus.registerQueryDbScan(name, () => {
|
|
291
303
|
purgeExpired();
|
|
292
|
-
return
|
|
304
|
+
return currentItems();
|
|
293
305
|
});
|
|
294
306
|
Bus.registerQueryDbStream(name, () => {
|
|
295
307
|
purgeExpired();
|
|
296
|
-
return Stream.fromIterable(
|
|
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));
|
|
297
313
|
});
|
|
298
314
|
return {
|
|
299
315
|
resources: [],
|
|
@@ -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)
|
|
@@ -331,6 +361,126 @@ let makeStorage = (
|
|
|
331
361
|
bus.registerQueryDb(name, ops)
|
|
332
362
|
bus.registerQueryDbScan(name, () => allRows())
|
|
333
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)
|
|
334
484
|
|
|
335
485
|
{
|
|
336
486
|
resources: [],
|
|
@@ -348,6 +498,8 @@ module Make = (Bus: LocalBus.T, DbProvider: {let db: SqliteDriver.t}) => {
|
|
|
348
498
|
registerQueryDb: Bus.registerQueryDb,
|
|
349
499
|
registerQueryDbScan: Bus.registerQueryDbScan,
|
|
350
500
|
registerQueryDbStream: Bus.registerQueryDbStream,
|
|
501
|
+
registerQueryDbIndexLookup: Bus.registerQueryDbIndexLookup,
|
|
502
|
+
registerQueryDbListPage: Bus.registerQueryDbListPage,
|
|
351
503
|
}
|
|
352
504
|
|
|
353
505
|
let make: QueryDb_Adapter.storageMaker<unit, unit> = (
|
|
@@ -4,8 +4,10 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Stream from "effect/Stream";
|
|
6
6
|
import * as Pulumi from "@pulumi/pulumi";
|
|
7
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
7
8
|
import * as LocalBus$ReventlessLocal from "../LocalBus.res.mjs";
|
|
8
9
|
import * as SqliteDriver$ReventlessLocal from "../SqliteDriver.res.mjs";
|
|
10
|
+
import * as QueryDbListQuery$ReventlessLocal from "./QueryDbListQuery.res.mjs";
|
|
9
11
|
|
|
10
12
|
function tableName(name) {
|
|
11
13
|
return "qdb_" + name.replaceAll("-", "_");
|
|
@@ -146,12 +148,27 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
146
148
|
let deleteBySubKeyStmt = SqliteDriver$ReventlessLocal.prepare(db, `DELETE FROM ` + table + ` WHERE partition_key = ? AND sub_key = ?`);
|
|
147
149
|
let scanAllStmt = SqliteDriver$ReventlessLocal.prepare(db, `SELECT partition_key, sub_key, item FROM ` + table + ` WHERE ` + notExpiredClause + ` ORDER BY partition_key, sub_key`);
|
|
148
150
|
let rowsFor = id => SqliteDriver$ReventlessLocal.all(selectByPartitionStmt, [id]).map(decodeItem);
|
|
149
|
-
let
|
|
151
|
+
let rowToItem = row => {
|
|
150
152
|
let item = decodeItem(row);
|
|
151
153
|
let match = row["partition_key"];
|
|
152
154
|
let partition = typeof match === "string" ? match : "";
|
|
153
155
|
return withId(item, partition);
|
|
154
|
-
}
|
|
156
|
+
};
|
|
157
|
+
let allRows = () => SqliteDriver$ReventlessLocal.all(scanAllStmt, []).map(rowToItem);
|
|
158
|
+
let indexLookupStmts = {};
|
|
159
|
+
let lookupByField = (field, value) => {
|
|
160
|
+
let s = indexLookupStmts[field];
|
|
161
|
+
let stmt;
|
|
162
|
+
if (s !== undefined) {
|
|
163
|
+
stmt = Primitive_option.valFromOption(s);
|
|
164
|
+
} else {
|
|
165
|
+
let escapedField = field.replaceAll("'", "''");
|
|
166
|
+
let s$1 = SqliteDriver$ReventlessLocal.prepare(db, `SELECT partition_key, sub_key, item FROM ` + table + ` WHERE json_extract(item, '$.` + escapedField + `') = ? AND ` + notExpiredClause);
|
|
167
|
+
indexLookupStmts[field] = s$1;
|
|
168
|
+
stmt = s$1;
|
|
169
|
+
}
|
|
170
|
+
return SqliteDriver$ReventlessLocal.all(stmt, [value]).map(rowToItem);
|
|
171
|
+
};
|
|
155
172
|
let saveOne = (id, state, ttl) => {
|
|
156
173
|
let subKey = computeSubKey(state, subIdField);
|
|
157
174
|
let expiresAt = ttl !== undefined ? ttl : null;
|
|
@@ -287,6 +304,83 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
287
304
|
bus.registerQueryDb(name, ops);
|
|
288
305
|
bus.registerQueryDbScan(name, () => allRows());
|
|
289
306
|
bus.registerQueryDbStream(name, () => Stream.fromIterable(allRows()));
|
|
307
|
+
bus.registerQueryDbIndexLookup(name, lookupByField);
|
|
308
|
+
let listStmtCache = {};
|
|
309
|
+
let preparedList = sql => {
|
|
310
|
+
let s = listStmtCache[sql];
|
|
311
|
+
if (s !== undefined) {
|
|
312
|
+
return Primitive_option.valFromOption(s);
|
|
313
|
+
}
|
|
314
|
+
let s$1 = SqliteDriver$ReventlessLocal.prepare(db, sql);
|
|
315
|
+
listStmtCache[sql] = s$1;
|
|
316
|
+
return s$1;
|
|
317
|
+
};
|
|
318
|
+
let jsonText = field => `CAST(json_extract(item, '$.` + field.replaceAll("'", "''") + `') AS TEXT)`;
|
|
319
|
+
let idExpr = "COALESCE(json_extract(item, '$.id'), partition_key)";
|
|
320
|
+
let listPage = (argsDict, capability, param) => {
|
|
321
|
+
let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
|
|
322
|
+
let strNonEmpty = k => Stdlib_Option.mapOr(Stdlib_Option.flatMap(filterDict[k], Stdlib_JSON.Decode.string), false, s => s.length > 0);
|
|
323
|
+
let hasIds = Stdlib_Option.mapOr(Stdlib_Option.flatMap(filterDict["ids"], Stdlib_JSON.Decode.array), false, a => a.length !== 0);
|
|
324
|
+
let last = Stdlib_Option.flatMap(argsDict["last"], Stdlib_JSON.Decode.float);
|
|
325
|
+
let before = Stdlib_Option.flatMap(argsDict["before"], Stdlib_JSON.Decode.string);
|
|
326
|
+
if (strNonEmpty("search") || strNonEmpty("searchPrefix") || hasIds || Stdlib_Option.isSome(last) || Stdlib_Option.isSome(before)) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
let whereParts = [notExpiredClause];
|
|
330
|
+
let params = [];
|
|
331
|
+
let valString = v => {
|
|
332
|
+
let s = Stdlib_JSON.Decode.string(v);
|
|
333
|
+
if (s !== undefined) {
|
|
334
|
+
return s;
|
|
335
|
+
} else {
|
|
336
|
+
return Stdlib_Option.map(Stdlib_JSON.Decode.float(v), f => f.toString());
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
capability.filterFields.forEach(f => {
|
|
340
|
+
let v = filterDict[f.name + "Eq"];
|
|
341
|
+
if (v !== undefined && v !== null) {
|
|
342
|
+
whereParts.push(jsonText(f.name) + ` = ?`);
|
|
343
|
+
params.push(Stdlib_Option.getOr(valString(v), ""));
|
|
344
|
+
}
|
|
345
|
+
if (!f.range) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let v$1 = filterDict[f.name + "From"];
|
|
349
|
+
if (v$1 !== undefined && v$1 !== null) {
|
|
350
|
+
whereParts.push(jsonText(f.name) + ` >= ?`);
|
|
351
|
+
params.push(Stdlib_Option.getOr(valString(v$1), ""));
|
|
352
|
+
}
|
|
353
|
+
let v$2 = filterDict[f.name + "To"];
|
|
354
|
+
if (v$2 !== undefined && v$2 !== null) {
|
|
355
|
+
whereParts.push(jsonText(f.name) + ` <= ?`);
|
|
356
|
+
params.push(Stdlib_Option.getOr(valString(v$2), ""));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
let orderByDict = Stdlib_Option.flatMap(argsDict["orderBy"], Stdlib_JSON.Decode.object);
|
|
361
|
+
let orderByField = Stdlib_Option.flatMap(Stdlib_Option.flatMap(orderByDict, ob => ob["field"]), Stdlib_JSON.Decode.string);
|
|
362
|
+
let isDesc = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(orderByDict, ob => ob["direction"]), Stdlib_JSON.Decode.string), "ASC") === "DESC";
|
|
363
|
+
let cursorExpr = orderByField !== undefined ? jsonText(orderByField) : idExpr;
|
|
364
|
+
let c = Stdlib_Option.flatMap(argsDict["after"], Stdlib_JSON.Decode.string);
|
|
365
|
+
if (c !== undefined) {
|
|
366
|
+
whereParts.push(cursorExpr + ` ` + (
|
|
367
|
+
isDesc ? "<" : ">"
|
|
368
|
+
) + ` ?`);
|
|
369
|
+
params.push(QueryDbListQuery$ReventlessLocal.decodeCursor(c));
|
|
370
|
+
}
|
|
371
|
+
let orderClause = orderByField !== undefined ? jsonText(orderByField) + ` ` + (
|
|
372
|
+
isDesc ? "DESC" : "ASC"
|
|
373
|
+
) + `, ` + idExpr + ` ASC` : idExpr + ` ASC`;
|
|
374
|
+
let pageSize = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(argsDict["first"], Stdlib_JSON.Decode.float), prim => prim | 0), QueryDbListQuery$ReventlessLocal.defaultListPageSize);
|
|
375
|
+
let sql = `SELECT partition_key, item FROM ` + table + ` WHERE ` + whereParts.join(" AND ") + ` ORDER BY ` + orderClause + ` LIMIT ?`;
|
|
376
|
+
let rows = SqliteDriver$ReventlessLocal.all(preparedList(sql), params.concat([pageSize + 1 | 0])).map(rowToItem);
|
|
377
|
+
let hasMore = rows.length > pageSize;
|
|
378
|
+
let pageItems = rows.slice(0, pageSize);
|
|
379
|
+
let cursorField = Stdlib_Option.getOr(orderByField, "id");
|
|
380
|
+
let cursorValueOf = item => Stdlib_Option.getOr(QueryDbListQuery$ReventlessLocal.getFieldString(item, cursorField), QueryDbListQuery$ReventlessLocal.getId(item));
|
|
381
|
+
return QueryDbListQuery$ReventlessLocal.buildConnection(pageItems, hasMore, false, cursorValueOf);
|
|
382
|
+
};
|
|
383
|
+
bus.registerQueryDbListPage(name, listPage);
|
|
290
384
|
return {
|
|
291
385
|
resources: [],
|
|
292
386
|
dataSourceName: Pulumi.output(""),
|
|
@@ -300,11 +394,15 @@ function Make(Bus) {
|
|
|
300
394
|
let busCallbacks_registerQueryDb = Bus.registerQueryDb;
|
|
301
395
|
let busCallbacks_registerQueryDbScan = Bus.registerQueryDbScan;
|
|
302
396
|
let busCallbacks_registerQueryDbStream = Bus.registerQueryDbStream;
|
|
397
|
+
let busCallbacks_registerQueryDbIndexLookup = Bus.registerQueryDbIndexLookup;
|
|
398
|
+
let busCallbacks_registerQueryDbListPage = Bus.registerQueryDbListPage;
|
|
303
399
|
let busCallbacks = {
|
|
304
400
|
publishStateChange: busCallbacks_publishStateChange,
|
|
305
401
|
registerQueryDb: busCallbacks_registerQueryDb,
|
|
306
402
|
registerQueryDbScan: busCallbacks_registerQueryDbScan,
|
|
307
|
-
registerQueryDbStream: busCallbacks_registerQueryDbStream
|
|
403
|
+
registerQueryDbStream: busCallbacks_registerQueryDbStream,
|
|
404
|
+
registerQueryDbIndexLookup: busCallbacks_registerQueryDbIndexLookup,
|
|
405
|
+
registerQueryDbListPage: busCallbacks_registerQueryDbListPage
|
|
308
406
|
};
|
|
309
407
|
let make = (name, indexes, subIdField, param, param$1, param$2, param$3) => makeStorage(DbProvider.db, busCallbacks, name, indexes, subIdField);
|
|
310
408
|
return {
|
|
@@ -27,7 +27,17 @@ let openDb = (~path) => {
|
|
|
27
27
|
if path !== ":memory:" {
|
|
28
28
|
mkdirSync(dirname(path), {recursive: true})
|
|
29
29
|
}
|
|
30
|
-
openDatabaseSync(path)
|
|
30
|
+
let db = openDatabaseSync(path)
|
|
31
|
+
// Connection pragmas. WAL lets readers proceed concurrently with a writer and
|
|
32
|
+
// batches fsyncs into the write-ahead log (a no-op for `:memory:`, which stays
|
|
33
|
+
// on its MEMORY journal); `synchronous=NORMAL` is the safe WAL companion —
|
|
34
|
+
// durable across app crashes, only a power-loss window remains — and cuts the
|
|
35
|
+
// per-commit fsync cost that dominated replay-heavy dev sessions;
|
|
36
|
+
// `busy_timeout` makes a momentarily-locked db wait rather than throw SQLITE_BUSY.
|
|
37
|
+
_exec(db, "PRAGMA journal_mode=WAL")
|
|
38
|
+
_exec(db, "PRAGMA synchronous=NORMAL")
|
|
39
|
+
_exec(db, "PRAGMA busy_timeout=5000")
|
|
40
|
+
db
|
|
31
41
|
}
|
|
32
42
|
let close = db => _close(db)
|
|
33
43
|
let exec = (db, sql) => _exec(db, sql)
|
|
@@ -19,7 +19,11 @@ function openDb(path) {
|
|
|
19
19
|
recursive: true
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
let db = new Nodesqlite.DatabaseSync(path);
|
|
23
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
24
|
+
db.exec("PRAGMA synchronous=NORMAL");
|
|
25
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
26
|
+
return db;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
function close(db) {
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Make = (Bus: LocalBus.T) => {
|
|
4
4
|
module RuntimeEnvironment = LocalRuntimeEnvironment
|
|
5
|
-
|
|
5
|
+
// MakeProjection (not Make): also registers the resolved handler for the
|
|
6
|
+
// SQLite backend's startup projection catch-up (ProjectionCheckpoint).
|
|
7
|
+
module EventCollectorChannel = LocalEventCollectorChannel.MakeProjection(Bus)
|
|
6
8
|
module EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder.Make(
|
|
7
9
|
Bus,
|
|
8
10
|
EventCollectorChannel,
|
|
@@ -9,8 +9,11 @@ import * as LocalEventCollectorChannel$ReventlessLocal from "../adapter/EventCol
|
|
|
9
9
|
import * as LocalEventCollectorRuntime_Builder$ReventlessLocal from "../adapter/Runtime/LocalEventCollectorRuntime_Builder.res.mjs";
|
|
10
10
|
|
|
11
11
|
function Make(Bus) {
|
|
12
|
-
let EventCollectorChannel = LocalEventCollectorChannel$ReventlessLocal.
|
|
13
|
-
let EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder$ReventlessLocal.Make(Bus)(
|
|
12
|
+
let EventCollectorChannel = LocalEventCollectorChannel$ReventlessLocal.MakeProjection(Bus);
|
|
13
|
+
let EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder$ReventlessLocal.Make(Bus)({
|
|
14
|
+
make: EventCollectorChannel.make,
|
|
15
|
+
connect: EventCollectorChannel.connect
|
|
16
|
+
});
|
|
14
17
|
let QueryDbStorage = QueryDbStorage_InMemory$ReventlessLocal.Make(Bus);
|
|
15
18
|
let QueryDbResolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
|
|
16
19
|
let Make$1 = Spec => (Mappings => ReadModel_Builder$ReventlessCore.Make(Spec)(Mappings)({
|
|
@@ -23,7 +26,10 @@ function Make(Bus) {
|
|
|
23
26
|
make: QueryDbStorage.make
|
|
24
27
|
})({
|
|
25
28
|
make: QueryDbResolvers.make
|
|
26
|
-
})(
|
|
29
|
+
})({
|
|
30
|
+
make: EventCollectorChannel.make,
|
|
31
|
+
connect: EventCollectorChannel.connect
|
|
32
|
+
})(EventCollectorRuntimeBuilder));
|
|
27
33
|
let NoResolvers = QueryDb_Adapter$ReventlessCore.NoResolvers({
|
|
28
34
|
make: QueryDbStorage.make
|
|
29
35
|
});
|
|
@@ -35,7 +41,10 @@ function Make(Bus) {
|
|
|
35
41
|
asEffectHandler: prim => prim
|
|
36
42
|
})({
|
|
37
43
|
make: QueryDbStorage.make
|
|
38
|
-
})(NoResolvers)(
|
|
44
|
+
})(NoResolvers)({
|
|
45
|
+
make: EventCollectorChannel.make,
|
|
46
|
+
connect: EventCollectorChannel.connect
|
|
47
|
+
})(EventCollectorRuntimeBuilder));
|
|
39
48
|
return {
|
|
40
49
|
RuntimeEnvironment: undefined,
|
|
41
50
|
EventCollectorChannel: EventCollectorChannel,
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
module Make = (Bus: LocalBus.T) => {
|
|
5
5
|
module RuntimeEnvironment = LocalRuntimeEnvironment
|
|
6
|
-
|
|
6
|
+
// MakeProjection (not Make): also registers the resolved handler for the
|
|
7
|
+
// SQLite backend's startup projection catch-up (ProjectionCheckpoint) —
|
|
8
|
+
// StateViewSlices are pure projections fed by DCB events.
|
|
9
|
+
module EventCollectorChannel = LocalEventCollectorChannel.MakeProjection(Bus)
|
|
7
10
|
module EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder.Make(
|
|
8
11
|
Bus,
|
|
9
12
|
EventCollectorChannel,
|
|
@@ -9,8 +9,11 @@ import * as LocalEventCollectorChannel$ReventlessLocal from "../adapter/EventCol
|
|
|
9
9
|
import * as LocalEventCollectorRuntime_Builder$ReventlessLocal from "../adapter/Runtime/LocalEventCollectorRuntime_Builder.res.mjs";
|
|
10
10
|
|
|
11
11
|
function Make(Bus) {
|
|
12
|
-
let EventCollectorChannel = LocalEventCollectorChannel$ReventlessLocal.
|
|
13
|
-
let EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder$ReventlessLocal.Make(Bus)(
|
|
12
|
+
let EventCollectorChannel = LocalEventCollectorChannel$ReventlessLocal.MakeProjection(Bus);
|
|
13
|
+
let EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder$ReventlessLocal.Make(Bus)({
|
|
14
|
+
make: EventCollectorChannel.make,
|
|
15
|
+
connect: EventCollectorChannel.connect
|
|
16
|
+
});
|
|
14
17
|
let QueryDbStorage = QueryDbStorage_InMemory$ReventlessLocal.Make(Bus);
|
|
15
18
|
let QueryDbResolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
|
|
16
19
|
let api = () => {};
|
|
@@ -29,7 +32,10 @@ function Make(Bus) {
|
|
|
29
32
|
make: QueryDbStorage.make
|
|
30
33
|
})({
|
|
31
34
|
make: QueryDbResolvers.make
|
|
32
|
-
})(
|
|
35
|
+
})({
|
|
36
|
+
make: EventCollectorChannel.make,
|
|
37
|
+
connect: EventCollectorChannel.connect
|
|
38
|
+
})(EventCollectorRuntimeBuilder)(Api);
|
|
33
39
|
let Make$1 = Spec => (Projection => {
|
|
34
40
|
let Inner = CoreMaker.Make(Spec)(Projection);
|
|
35
41
|
return {
|