@reventlessdev/reventless-local 3.0.0-alpha.225 → 3.0.0-alpha.227
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 +11 -11
- package/src/Platform.res +5 -6
- package/src/Platform.res.mjs +3 -5
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +1 -1
- package/src/adapter/QueryDb/LocalQueryDbStorage.res +1 -1
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +223 -22
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +176 -18
- package/tests/UiHintsTest.res +33 -6
- package/tests/UiHintsTest.res.mjs +15 -2
- package/tests/adapter/DcbEventLogStorageSqliteTest.res +1 -1
- package/tests/adapter/DcbEventLogStorageTest.res +1 -1
- package/tests/adapter/EventLogSnapshotParityTest.res +1 -1
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +9 -7
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +5 -5
- package/tests/adapter/QueryDbListResolverTest.res +82 -2
- package/tests/adapter/QueryDbListResolverTest.res.mjs +106 -11
- package/tests/components/readmodel/DcbReadModelE2EFixtures.res +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.227 (2026-08-18)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **api:** declare the reference door in the SDL every backend is built from ([5c1857e](https://github.com/ReventlessDev/reventless-core/commit/5c1857ea90ff40305a1c44a9e57043528e6a93aa))
|
|
11
|
+
* **api:** make the by-index door answer, and let an elevated caller widen it ([0fe0c6f](https://github.com/ReventlessDev/reventless-core/commit/0fe0c6f8dec6228ecaba39577e28d780b4f79c83))
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* **core:** let a reference name a retired row, and let an elevated caller open one ([9e2623a](https://github.com/ReventlessDev/reventless-core/commit/9e2623a4b22487561607fcc0ca19d51726069ee4))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# 3.0.0-alpha.226 (2026-08-16)
|
|
18
|
+
|
|
19
|
+
**Note:** Version bump only for package @reventlessdev/reventless-local
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
6
25
|
# 3.0.0-alpha.225 (2026-08-16)
|
|
7
26
|
|
|
8
27
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.227",
|
|
4
4
|
"description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -36,22 +36,22 @@
|
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
38
38
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
39
|
-
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
40
39
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
41
|
-
"@reventlessdev/rescript-
|
|
40
|
+
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
41
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
42
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.239",
|
|
42
43
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
43
|
-
"@reventlessdev/reventless-
|
|
44
|
-
"@reventlessdev/reventless-
|
|
45
|
-
"@reventlessdev/reventless-
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-
|
|
48
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
49
|
-
"@reventlessdev/reventless-seed": "1.0.0-alpha.15"
|
|
44
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.186",
|
|
45
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.87",
|
|
46
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.145",
|
|
47
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.103",
|
|
48
|
+
"@reventlessdev/reventless-seed": "1.0.0-alpha.16",
|
|
49
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.117"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"rescript": "12.3.0",
|
|
53
53
|
"sury-ppx": "11.0.0-alpha.2",
|
|
54
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
54
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.69"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"rescript": "12.3.0"
|
package/src/Platform.res
CHANGED
|
@@ -700,7 +700,7 @@ module MakeWithConfig = (
|
|
|
700
700
|
StateViewSliceMaker.Make(Spec, Projection)
|
|
701
701
|
}
|
|
702
702
|
|
|
703
|
-
// Admin UI-fragment registry (docs/plans/event-sourced-fragment-registries.md): the
|
|
703
|
+
// Admin UI-fragment registry (docs/plans/done/event-sourced-fragment-registries.md): the
|
|
704
704
|
// platform UI-fragment registry hosted as an admin DCB slice on the admin DcbEventLog.
|
|
705
705
|
// Built once and passed into Admin.construct below.
|
|
706
706
|
module UiFragmentRegistrySlice = StateChangeSlice.Make(
|
|
@@ -1428,11 +1428,10 @@ module MakeWithConfig = (
|
|
|
1428
1428
|
switch entry.indexQueries {
|
|
1429
1429
|
| Some(indexes) =>
|
|
1430
1430
|
indexes->Array.forEach((ic: Reventless.ReadModel.indexConfig) => {
|
|
1431
|
-
let
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
let fieldName = entry.singleFieldName ++ "By" ++ stripped->String.capitalize
|
|
1431
|
+
let fieldName = ReventlessCore.GraphQL_FragmentGenerator.indexQueryFieldName(
|
|
1432
|
+
~singleFieldName=entry.singleFieldName,
|
|
1433
|
+
~index=ic.index,
|
|
1434
|
+
)
|
|
1436
1435
|
queryResolvers->Dict.set(fieldName, async (_root, _args, _ctx): JSON.t =>
|
|
1437
1436
|
connectionResponse([])
|
|
1438
1437
|
)
|
package/src/Platform.res.mjs
CHANGED
|
@@ -9,7 +9,6 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
|
9
9
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
10
10
|
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
11
11
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
12
|
-
import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
13
12
|
import * as Effect from "effect/Effect";
|
|
14
13
|
import * as Pulumi from "@pulumi/pulumi";
|
|
15
14
|
import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
|
|
@@ -81,6 +80,7 @@ import * as LocalDcbEventLogStorage$ReventlessLocal from "./adapter/DcbEventLog/
|
|
|
81
80
|
import * as LocalRuntimeEnvironment$ReventlessLocal from "./adapter/Runtime/LocalRuntimeEnvironment.res.mjs";
|
|
82
81
|
import * as LocalScheduledPublisher$ReventlessLocal from "./adapter/Scheduler/LocalScheduledPublisher.res.mjs";
|
|
83
82
|
import * as Platform_Admin_Structure$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin_Structure.res.mjs";
|
|
83
|
+
import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
84
84
|
import * as LocalCommandTopicChannel$ReventlessLocal from "./adapter/CommandTopic/LocalCommandTopicChannel.res.mjs";
|
|
85
85
|
import * as LocalEventTopicPublisher$ReventlessLocal from "./adapter/EventTopic/LocalEventTopicPublisher.res.mjs";
|
|
86
86
|
import * as StateChangeSlice_Builder$ReventlessLocal from "./components/StateChangeSlice_Builder.res.mjs";
|
|
@@ -1206,8 +1206,7 @@ function MakeWithConfig(Config) {
|
|
|
1206
1206
|
let indexes = entry.indexQueries;
|
|
1207
1207
|
if (indexes !== undefined) {
|
|
1208
1208
|
indexes.forEach(ic => {
|
|
1209
|
-
let
|
|
1210
|
-
let fieldName = entry.singleFieldName + "By" + Stdlib_String.capitalize(stripped);
|
|
1209
|
+
let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(entry.singleFieldName, ic.index);
|
|
1211
1210
|
queryResolvers[fieldName] = async (_root, _args, _ctx) => connectionResponse([]);
|
|
1212
1211
|
});
|
|
1213
1212
|
return;
|
|
@@ -2933,8 +2932,7 @@ function Make($star) {
|
|
|
2933
2932
|
let indexes = entry.indexQueries;
|
|
2934
2933
|
if (indexes !== undefined) {
|
|
2935
2934
|
indexes.forEach(ic => {
|
|
2936
|
-
let
|
|
2937
|
-
let fieldName = entry.singleFieldName + "By" + Stdlib_String.capitalize(stripped);
|
|
2935
|
+
let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(entry.singleFieldName, ic.index);
|
|
2938
2936
|
queryResolvers[fieldName] = async (_root, _args, _ctx) => connectionResponse([]);
|
|
2939
2937
|
});
|
|
2940
2938
|
return;
|
|
@@ -20,7 +20,7 @@ let ensureSchema = (db: SqliteDriver.t) => {
|
|
|
20
20
|
db->SqliteDriver.exec(
|
|
21
21
|
"CREATE TABLE IF NOT EXISTS event_log (log_name TEXT NOT NULL, aggregate_id TEXT NOT NULL, seq_nr INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (log_name, aggregate_id, seq_nr))",
|
|
22
22
|
)
|
|
23
|
-
// Keep-one aggregate-state snapshots (docs/plans/aggregate-snapshotting.md):
|
|
23
|
+
// Keep-one aggregate-state snapshots (docs/plans/done/aggregate-snapshotting.md):
|
|
24
24
|
// one row per (log, aggregate), overwritten on every snapshot write. `state`
|
|
25
25
|
// is the JSON-encoded fold of events seq 0..seq_nr-1; `schema_hash` lets the
|
|
26
26
|
// consumer detect a state-schema drift and fall back to full replay.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// refactor. So under Backend.Postgres the read-model live-query path uses the
|
|
10
10
|
// in-memory arm (getDb() is None for Postgres → Memory), rebuilt by replay from
|
|
11
11
|
// the durable pg event log; the deploy-usable ReventlessPostgres.QueryDbStorage_Postgres
|
|
12
|
-
// serves non-local compute layers. See docs/plans/postgres-storage-adapter.md §E.
|
|
12
|
+
// serves non-local compute layers. See docs/plans/done/postgres-storage-adapter.md §E.
|
|
13
13
|
|
|
14
14
|
module Make = (Bus: LocalBus.T) => {
|
|
15
15
|
module Mem = QueryDbStorage_InMemory.Make(Bus)
|
|
@@ -228,8 +228,6 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
228
228
|
)
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
let cap = s => s->String.charAt(0)->String.toUpperCase ++ s->String.slice(~start=1)
|
|
232
|
-
|
|
233
231
|
// Resolve query field names: check registry first, fall back to safe defaults.
|
|
234
232
|
// Fallbacks use simple GraphQL built-in types to avoid referencing non-existent custom types.
|
|
235
233
|
let registryEntry = Plugin_Helpers.queryFieldNamesRegistry->Dict.get(name)
|
|
@@ -271,10 +269,16 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
271
269
|
}
|
|
272
270
|
|
|
273
271
|
// -- Main query: getById ---------------------------------------------------
|
|
272
|
+
// `includeRetired` here matches the connection field and the AppSync SDL the
|
|
273
|
+
// FragmentGenerator emits. The resolver below already reads it — `retiredAllows`
|
|
274
|
+
// consults `askedForRetired(~args)` on every door — so this argument was the
|
|
275
|
+
// only missing half, and without it an elevated caller could see an archived
|
|
276
|
+
// row in a list and not open it.
|
|
274
277
|
let byIdSdl = if includeIdParam {
|
|
275
278
|
switch subIdField {
|
|
276
|
-
| Some(sf) =>
|
|
277
|
-
|
|
279
|
+
| Some(sf) =>
|
|
280
|
+
` ${singleQueryName}(id: ID!, ${sf}: String, includeRetired: Boolean): ${returnTypeName}`
|
|
281
|
+
| None => ` ${singleQueryName}(id: ID!, includeRetired: Boolean): ${returnTypeName}`
|
|
278
282
|
}
|
|
279
283
|
} else {
|
|
280
284
|
` ${singleQueryName}: ${returnTypeName}`
|
|
@@ -402,6 +406,124 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
402
406
|
| None => "id"
|
|
403
407
|
}
|
|
404
408
|
|
|
409
|
+
// -- Reference door: {listFieldName}Refs -----------------------------------
|
|
410
|
+
// Names the rows a caller already holds pointers to, and nothing more. Three
|
|
411
|
+
// fields, fixed by the type, so this cannot be widened by what a caller
|
|
412
|
+
// selects.
|
|
413
|
+
//
|
|
414
|
+
// Retirement is lifted here only where the view declared `@namedWhenRetired`.
|
|
415
|
+
// Ownership is not lifted at all, and the asymmetry is the point: an archived
|
|
416
|
+
// product is a thing the shop sold and every order naming one may say what it
|
|
417
|
+
// was, while a deactivated customer is somebody's row before it is anything
|
|
418
|
+
// else. One predicate answers "is this row still on offer", the other "is this
|
|
419
|
+
// row yours", and only the first is what an archive withdraws.
|
|
420
|
+
let refsSdl = if includeIdParam && subIdField === None {
|
|
421
|
+
[GraphQL_FragmentGenerator.deriveRefsQueryField(~listFieldName=listQueryName, ~returnTypeName)]
|
|
422
|
+
} else {
|
|
423
|
+
[]
|
|
424
|
+
}
|
|
425
|
+
let refsResolverEntry: option<(string, ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn)> =
|
|
426
|
+
if includeIdParam && subIdField === None {
|
|
427
|
+
server.registerTypes(~sdlTypes=[GraphQL_FragmentGenerator.deriveRefTypeSdl(~returnTypeName)])
|
|
428
|
+
let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
|
|
429
|
+
_root,
|
|
430
|
+
args,
|
|
431
|
+
ctx,
|
|
432
|
+
) => {
|
|
433
|
+
switch await runInterceptor(~ctx, ~args) {
|
|
434
|
+
| Deny(_) => []->JSON.Encode.array
|
|
435
|
+
| Allow =>
|
|
436
|
+
let ids =
|
|
437
|
+
args
|
|
438
|
+
->JSON.Decode.object
|
|
439
|
+
->Option.flatMap(d => d->Dict.get("ids"))
|
|
440
|
+
->Option.flatMap(JSON.Decode.array)
|
|
441
|
+
->Option.getOr([])
|
|
442
|
+
->Array.filterMap(JSON.Decode.string)
|
|
443
|
+
let spec = retiredSpecOf()
|
|
444
|
+
let namesRetired = spec->Option.mapOr(false, r => r.namedWhenRetired)
|
|
445
|
+
// The row's own retirement, read with the same `isRetiredValue` every
|
|
446
|
+
// other door narrows by — so "retired" here and "withheld" there are
|
|
447
|
+
// one answer, not two that drift.
|
|
448
|
+
let retirementOf = (item: JSON.t) =>
|
|
449
|
+
switch spec {
|
|
450
|
+
| None => (false, None)
|
|
451
|
+
| Some(r) =>
|
|
452
|
+
let cell = item->JSON.Decode.object->Option.flatMap(d => d->Dict.get(r.field))
|
|
453
|
+
let scope: Reventless.OwnerScope.retiredScope = {field: r.field, values: r.values}
|
|
454
|
+
let retired = scope->Reventless.OwnerScope.isRetiredValue(cell)
|
|
455
|
+
(
|
|
456
|
+
retired,
|
|
457
|
+
// The state that retired it, and only that. A live row reports
|
|
458
|
+
// none — this door names rows, it does not publish a lifecycle
|
|
459
|
+
// column to callers the list withholds. Nor does the boolean
|
|
460
|
+
// form, where the field is the state and `retired` said it.
|
|
461
|
+
retired && r.values->Option.isSome
|
|
462
|
+
? cell->Option.flatMap(JSON.Decode.string)
|
|
463
|
+
: None,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
switch Bus.getQueryDb(name) {
|
|
467
|
+
| Some(ops) =>
|
|
468
|
+
let load = key =>
|
|
469
|
+
ops.loadStream(key)
|
|
470
|
+
->Stream.runCollect
|
|
471
|
+
->Effect.catchAll(_ => Effect.succeed([]))
|
|
472
|
+
->Effect.runPromise
|
|
473
|
+
let loaded = await ids->Array.map(async id =>
|
|
474
|
+
switch (await load(id))->Array.get(0) {
|
|
475
|
+
| Some(item) => (id, Some(item))
|
|
476
|
+
| None =>
|
|
477
|
+
switch Api_Ids.alternateKey(id) {
|
|
478
|
+
| Some(localId) => (localId, (await load(localId))->Array.get(0))
|
|
479
|
+
| None => (id, None)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
)->Promise.all
|
|
483
|
+
loaded
|
|
484
|
+
->Array.filterMap(((id, opt)) =>
|
|
485
|
+
opt
|
|
486
|
+
->Option.filter(item => ownerAllows(~ctx, item))
|
|
487
|
+
->Option.flatMap(item => {
|
|
488
|
+
let (retired, state) = retirementOf(item)
|
|
489
|
+
// A retired row leaves through this door only where the view
|
|
490
|
+
// said it may. Where it did not, the door answers exactly as
|
|
491
|
+
// every other one does — with nothing.
|
|
492
|
+
if retired && !namesRetired {
|
|
493
|
+
None
|
|
494
|
+
} else {
|
|
495
|
+
let label =
|
|
496
|
+
item
|
|
497
|
+
->JSON.Decode.object
|
|
498
|
+
->Option.flatMap(d => d->Dict.get(labelField))
|
|
499
|
+
->Option.flatMap(JSON.Decode.string)
|
|
500
|
+
// A view with no label field resolves to its id, which is
|
|
501
|
+
// what `labelField`'s own fallback already decided.
|
|
502
|
+
->Option.getOr(id)
|
|
503
|
+
Some(
|
|
504
|
+
Dict.fromArray([
|
|
505
|
+
("id", JSON.Encode.string(id)),
|
|
506
|
+
("label", JSON.Encode.string(label)),
|
|
507
|
+
("retired", JSON.Encode.bool(retired)),
|
|
508
|
+
(
|
|
509
|
+
"retiredState",
|
|
510
|
+
state->Option.mapOr(JSON.Encode.null, JSON.Encode.string),
|
|
511
|
+
),
|
|
512
|
+
])->JSON.Encode.object,
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
)
|
|
517
|
+
->JSON.Encode.array
|
|
518
|
+
| None => []->JSON.Encode.array
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
Some((listQueryName ++ "Refs", resolver))
|
|
523
|
+
} else {
|
|
524
|
+
None
|
|
525
|
+
}
|
|
526
|
+
|
|
405
527
|
// -- List query -------------------------------------------------------------
|
|
406
528
|
// Look up the registered state schema (populated alongside queryFieldNamesRegistry)
|
|
407
529
|
// so the resolver derives the same serverCapability the FragmentGenerator emitted.
|
|
@@ -666,20 +788,95 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
666
788
|
}
|
|
667
789
|
|
|
668
790
|
// -- Index queries: {name}By{Index} ---------------------------------------
|
|
791
|
+
// Name, argument and return type all come from `GraphQL_FragmentGenerator`,
|
|
792
|
+
// which is also where the AppSync SDL gets them. They used to be spelled out
|
|
793
|
+
// here independently and disagreed with that emitter on all three, so the
|
|
794
|
+
// same door had a different signature per backend and worked on neither.
|
|
669
795
|
let indexSdlFields = indexes->Array.map((ic: Reventless.ReadModel.indexConfig) =>
|
|
670
|
-
|
|
796
|
+
GraphQL_FragmentGenerator.deriveIndexQueryField(
|
|
797
|
+
~singleFieldName=singleQueryName,
|
|
798
|
+
~indexConfig=ic,
|
|
799
|
+
~connectionTypeName=returnTypeName ++ "Connection",
|
|
800
|
+
)
|
|
671
801
|
)
|
|
802
|
+
// Backward paging is refused rather than ignored, and refused on both
|
|
803
|
+
// backends rather than on the one that cannot do it. `listAllItemsConnection`
|
|
804
|
+
// already sets the rule for a door whose read cannot walk backwards — "fail
|
|
805
|
+
// loud rather than silently returning the forward page" — and its reasoning
|
|
806
|
+
// is about the answer, not about DynamoDB: `last: 2` handed the first two
|
|
807
|
+
// rows is a different question answered without saying so.
|
|
808
|
+
//
|
|
809
|
+
// The arguments stay in the SDL. One that appeared and disappeared with the
|
|
810
|
+
// backend would make adding a sort key a breaking schema change and force
|
|
811
|
+
// every client to feature-detect, which is the same argument `includeRetired`
|
|
812
|
+
// and the reference door are emitted unconditionally for.
|
|
813
|
+
let rejectBackwardPaging = (~args) => {
|
|
814
|
+
let given = key =>
|
|
815
|
+
switch args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key)) {
|
|
816
|
+
| None | Some(JSON.Null) => false
|
|
817
|
+
| Some(_) => true
|
|
818
|
+
}
|
|
819
|
+
if given("last") || given("before") {
|
|
820
|
+
throw(
|
|
821
|
+
GraphQL_CallerError.badUserInput(
|
|
822
|
+
"Backward pagination (last/before) is not supported on by-index connections; use first/after.",
|
|
823
|
+
),
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// A positional connection. This door answers an equality lookup on one index
|
|
828
|
+
// value and has no ordering key to cut a cursor from — the list door borrows
|
|
829
|
+
// its sort key because it orders by one. An offset is stable for as long as
|
|
830
|
+
// a page-forward takes, which is what the field advertises and no more.
|
|
831
|
+
let indexConnection = (~args, items: array<JSON.t>) => {
|
|
832
|
+
let arg = key => args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))
|
|
833
|
+
let after =
|
|
834
|
+
arg("after")->Option.flatMap(JSON.Decode.string)->Option.flatMap(s => Int.fromString(s))->Option.getOr(-1)
|
|
835
|
+
let first =
|
|
836
|
+
arg("first")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)->Option.getOr(50)
|
|
837
|
+
let start = after + 1
|
|
838
|
+
let page = items->Array.slice(~start, ~end=start + first)
|
|
839
|
+
let taken = page->Array.length
|
|
840
|
+
let edges =
|
|
841
|
+
page->Array.mapWithIndex((item, i) =>
|
|
842
|
+
Dict.fromArray([
|
|
843
|
+
("node", item),
|
|
844
|
+
("cursor", (start + i)->Int.toString->JSON.Encode.string),
|
|
845
|
+
])->JSON.Encode.object
|
|
846
|
+
)
|
|
847
|
+
let cursorAt = i => i->Int.toString->JSON.Encode.string
|
|
848
|
+
Dict.fromArray([
|
|
849
|
+
("edges", edges->JSON.Encode.array),
|
|
850
|
+
(
|
|
851
|
+
"pageInfo",
|
|
852
|
+
Dict.fromArray([
|
|
853
|
+
("hasNextPage", JSON.Encode.bool(start + taken < items->Array.length)),
|
|
854
|
+
("hasPreviousPage", JSON.Encode.bool(start > 0)),
|
|
855
|
+
("startCursor", taken > 0 ? cursorAt(start) : JSON.Encode.null),
|
|
856
|
+
("endCursor", taken > 0 ? cursorAt(start + taken - 1) : JSON.Encode.null),
|
|
857
|
+
])->JSON.Encode.object,
|
|
858
|
+
),
|
|
859
|
+
])->JSON.Encode.object
|
|
860
|
+
}
|
|
672
861
|
let indexResolvers: array<(string, ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn)> = indexes->Array.map(
|
|
673
862
|
(ic: Reventless.ReadModel.indexConfig) => {
|
|
674
|
-
let
|
|
675
|
-
|
|
676
|
-
|
|
863
|
+
let resolverName = GraphQL_FragmentGenerator.indexQueryFieldName(
|
|
864
|
+
~singleFieldName=singleQueryName,
|
|
865
|
+
~index=ic.index,
|
|
866
|
+
)
|
|
867
|
+
// The argument the caller passes and the row field this filters on are
|
|
868
|
+
// one and the same — the door reads the index's key, not its name.
|
|
869
|
+
let filterField = GraphQL_FragmentGenerator.indexKeyField(ic)
|
|
677
870
|
let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (_root, args, ctx) => {
|
|
678
871
|
switch await runInterceptor(~ctx, ~args) {
|
|
679
|
-
| Deny(_) => []
|
|
872
|
+
| Deny(_) => indexConnection(~args, [])
|
|
680
873
|
| Allow =>
|
|
874
|
+
// After the interceptor, matching the AppSync pipeline, where the
|
|
875
|
+
// interceptor leads the chain and the query function's `request`
|
|
876
|
+
// raises this second.
|
|
877
|
+
rejectBackwardPaging(~args)
|
|
681
878
|
let value =
|
|
682
|
-
args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(
|
|
879
|
+
args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(filterField))->Option.flatMap(JSON.Decode.string)->Option.getOr("")
|
|
683
880
|
// Applied to whichever arm answers, rather than inside one of them: the
|
|
684
881
|
// push-down and the scan are two ways to reach the same rows, and a
|
|
685
882
|
// narrowing that lives in only one is a hole that appears when a
|
|
@@ -690,22 +887,24 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
690
887
|
// in-memory reuses its lazy snapshot). Fall back to scan+filter only
|
|
691
888
|
// if no lookup is registered for this QueryDb.
|
|
692
889
|
switch Bus.getQueryDbIndexLookup(name) {
|
|
693
|
-
| Some(lookup) => lookup(filterField, value)->scoped
|
|
890
|
+
| Some(lookup) => indexConnection(~args, lookup(filterField, value)->scoped)
|
|
694
891
|
| None =>
|
|
695
892
|
switch Bus.getQueryDbScan(name) {
|
|
696
893
|
| Some(scanAll) =>
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
->
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
894
|
+
indexConnection(
|
|
895
|
+
~args,
|
|
896
|
+
scanAll()
|
|
897
|
+
->Array.filter(item =>
|
|
898
|
+
item
|
|
899
|
+
->JSON.Decode.object
|
|
900
|
+
->Option.flatMap(d => d->Dict.get(filterField))
|
|
901
|
+
->Option.flatMap(JSON.Decode.string)
|
|
902
|
+
->Option.map(v => v == value)
|
|
903
|
+
->Option.getOr(false)
|
|
904
|
+
)
|
|
905
|
+
->scoped,
|
|
705
906
|
)
|
|
706
|
-
|
|
707
|
-
->JSON.Encode.array
|
|
708
|
-
| None => []->JSON.Encode.array
|
|
907
|
+
| None => indexConnection(~args, [])
|
|
709
908
|
}
|
|
710
909
|
}
|
|
711
910
|
}
|
|
@@ -718,6 +917,7 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
718
917
|
let allSdl =
|
|
719
918
|
[byIdSdl]
|
|
720
919
|
->Array.concat(byIdsSdl)
|
|
920
|
+
->Array.concat(refsSdl)
|
|
721
921
|
->Array.concat(listSdl)
|
|
722
922
|
->Array.concat(itemsSdl)
|
|
723
923
|
->Array.concat(indexSdlFields)
|
|
@@ -725,6 +925,7 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
725
925
|
let resolvers = Dict.make()
|
|
726
926
|
resolvers->Dict.set(singleQueryName, byIdResolver)
|
|
727
927
|
byIdsResolverEntry->Option.forEach(((k, v)) => resolvers->Dict.set(k, v))
|
|
928
|
+
refsResolverEntry->Option.forEach(((k, v)) => resolvers->Dict.set(k, v))
|
|
728
929
|
listResolvers->Array.forEach(((k, v)) => resolvers->Dict.set(k, v))
|
|
729
930
|
itemsResolvers->Array.forEach(((k, v)) => resolvers->Dict.set(k, v))
|
|
730
931
|
indexResolvers->Array.forEach(((k, v)) => resolvers->Dict.set(k, v))
|