@reventlessdev/reventless-local 3.0.0-alpha.226 → 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 +11 -0
- package/package.json +11 -11
- package/src/Platform.res +4 -5
- package/src/Platform.res.mjs +3 -5
- 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/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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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
|
+
|
|
6
17
|
# 3.0.0-alpha.226 (2026-08-16)
|
|
7
18
|
|
|
8
19
|
**Note:** Version bump only for package @reventlessdev/reventless-local
|
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": {
|
|
@@ -35,23 +35,23 @@
|
|
|
35
35
|
"sury": "11.0.0-alpha.4",
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
38
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
39
38
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
39
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
40
40
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
41
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.
|
|
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-
|
|
49
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.116"
|
|
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
|
@@ -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;
|
|
@@ -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))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
|
|
4
|
+
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
4
5
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
6
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
7
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
@@ -16,6 +17,7 @@ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/s
|
|
|
16
17
|
import * as SortKey_Filter$ReventlessLocal from "./SortKey_Filter.res.mjs";
|
|
17
18
|
import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
|
|
18
19
|
import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
|
|
20
|
+
import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
|
|
19
21
|
import * as DomainGraphQL_Server$ReventlessLocal from "../DomainGraphQL_Server.res.mjs";
|
|
20
22
|
import * as OwnerScopeDiagnostics$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/OwnerScopeDiagnostics.res.mjs";
|
|
21
23
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
@@ -112,7 +114,6 @@ function Make(Bus) {
|
|
|
112
114
|
return true;
|
|
113
115
|
}
|
|
114
116
|
};
|
|
115
|
-
let cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
|
116
117
|
let registryEntry = Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name];
|
|
117
118
|
let singleQueryName = registryEntry !== undefined ? registryEntry.singleFieldName : name.charAt(0).toLowerCase() + name.slice(1);
|
|
118
119
|
let listQueryName = registryEntry !== undefined ? registryEntry.listFieldName : name + "s";
|
|
@@ -124,7 +125,7 @@ function Make(Bus) {
|
|
|
124
125
|
relay.registerNodeType(returnTypeName, name);
|
|
125
126
|
}
|
|
126
127
|
let byIdSdl = includeIdParam ? (
|
|
127
|
-
subIdField !== undefined ? ` ` + singleQueryName + `(id: ID!, ` + subIdField + `: String): ` + returnTypeName : ` ` + singleQueryName + `(id: ID
|
|
128
|
+
subIdField !== undefined ? ` ` + singleQueryName + `(id: ID!, ` + subIdField + `: String, includeRetired: Boolean): ` + returnTypeName : ` ` + singleQueryName + `(id: ID!, includeRetired: Boolean): ` + returnTypeName
|
|
128
129
|
) : ` ` + singleQueryName + `: ` + returnTypeName;
|
|
129
130
|
let byIdResolver = async (_root, args, ctx) => {
|
|
130
131
|
let match = await runInterceptor(ctx, args);
|
|
@@ -219,6 +220,101 @@ function Make(Bus) {
|
|
|
219
220
|
byIdsResolverEntry = undefined;
|
|
220
221
|
}
|
|
221
222
|
let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
|
|
223
|
+
let refsSdl = includeIdParam && subIdField === undefined ? [GraphQL_FragmentGenerator$ReventlessCore.deriveRefsQueryField(listQueryName, returnTypeName)] : [];
|
|
224
|
+
let refsResolverEntry;
|
|
225
|
+
if (includeIdParam && subIdField === undefined) {
|
|
226
|
+
server.registerTypes([GraphQL_FragmentGenerator$ReventlessCore.deriveRefTypeSdl(returnTypeName)]);
|
|
227
|
+
let resolver$1 = async (_root, args, ctx) => {
|
|
228
|
+
let match = await runInterceptor(ctx, args);
|
|
229
|
+
if (typeof match === "object") {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
let ids = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d["ids"]), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
|
|
233
|
+
let spec = retiredSpecOf();
|
|
234
|
+
let namesRetired = Stdlib_Option.mapOr(spec, false, r => r.namedWhenRetired);
|
|
235
|
+
let retirementOf = item => {
|
|
236
|
+
if (spec === undefined) {
|
|
237
|
+
return [
|
|
238
|
+
false,
|
|
239
|
+
undefined
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
let cell = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[spec.field]);
|
|
243
|
+
let scope_field = spec.field;
|
|
244
|
+
let scope_values = spec.values;
|
|
245
|
+
let scope = {
|
|
246
|
+
field: scope_field,
|
|
247
|
+
values: scope_values
|
|
248
|
+
};
|
|
249
|
+
let retired = OwnerScope$Reventless.isRetiredValue(scope, cell);
|
|
250
|
+
return [
|
|
251
|
+
retired,
|
|
252
|
+
retired && Stdlib_Option.isSome(spec.values) ? Stdlib_Option.flatMap(cell, Stdlib_JSON.Decode.string) : undefined
|
|
253
|
+
];
|
|
254
|
+
};
|
|
255
|
+
let ops = Bus.getQueryDb(name);
|
|
256
|
+
if (ops === undefined) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
let load = key => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
|
|
260
|
+
let loaded = await Promise.all(ids.map(async id => {
|
|
261
|
+
let item = (await load(id))[0];
|
|
262
|
+
if (item !== undefined) {
|
|
263
|
+
return [
|
|
264
|
+
id,
|
|
265
|
+
item
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
let localId = Api_Ids$ReventlessCore.alternateKey(id);
|
|
269
|
+
if (localId !== undefined) {
|
|
270
|
+
return [
|
|
271
|
+
localId,
|
|
272
|
+
(await load(localId))[0]
|
|
273
|
+
];
|
|
274
|
+
} else {
|
|
275
|
+
return [
|
|
276
|
+
id,
|
|
277
|
+
undefined
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
}));
|
|
281
|
+
return Stdlib_Array.filterMap(loaded, param => {
|
|
282
|
+
let id = param[0];
|
|
283
|
+
return Stdlib_Option.flatMap(Stdlib_Option.filter(param[1], item => ownerAllows(ctx, item)), item => {
|
|
284
|
+
let match = retirementOf(item);
|
|
285
|
+
let retired = match[0];
|
|
286
|
+
if (retired && !namesRetired) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
let label = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[labelField]), Stdlib_JSON.Decode.string), id);
|
|
290
|
+
return Object.fromEntries([
|
|
291
|
+
[
|
|
292
|
+
"id",
|
|
293
|
+
id
|
|
294
|
+
],
|
|
295
|
+
[
|
|
296
|
+
"label",
|
|
297
|
+
label
|
|
298
|
+
],
|
|
299
|
+
[
|
|
300
|
+
"retired",
|
|
301
|
+
retired
|
|
302
|
+
],
|
|
303
|
+
[
|
|
304
|
+
"retiredState",
|
|
305
|
+
Stdlib_Option.mapOr(match[1], null, prim => prim)
|
|
306
|
+
]
|
|
307
|
+
]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
refsResolverEntry = [
|
|
312
|
+
listQueryName + "Refs",
|
|
313
|
+
resolver$1
|
|
314
|
+
];
|
|
315
|
+
} else {
|
|
316
|
+
refsResolverEntry = undefined;
|
|
317
|
+
}
|
|
222
318
|
let stateSchemaOpt = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name];
|
|
223
319
|
let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
|
|
224
320
|
let fetchAllItems = async () => {
|
|
@@ -250,7 +346,7 @@ function Make(Bus) {
|
|
|
250
346
|
endCursor: null
|
|
251
347
|
}
|
|
252
348
|
};
|
|
253
|
-
let resolver$
|
|
349
|
+
let resolver$2 = async (_root, args, ctx) => {
|
|
254
350
|
let match = await runInterceptor(ctx, args);
|
|
255
351
|
if (typeof match === "object") {
|
|
256
352
|
return emptyConnection;
|
|
@@ -277,11 +373,11 @@ function Make(Bus) {
|
|
|
277
373
|
};
|
|
278
374
|
match = [
|
|
279
375
|
sdl,
|
|
280
|
-
resolver$
|
|
376
|
+
resolver$2
|
|
281
377
|
];
|
|
282
378
|
} else {
|
|
283
379
|
let sdl$1 = [` ` + listQueryName + `(nextToken: String, limit: Int): ` + pluralTypeName + `!`];
|
|
284
|
-
let resolver$
|
|
380
|
+
let resolver$3 = async (_root, args, ctx) => {
|
|
285
381
|
let match = await runInterceptor(ctx, args);
|
|
286
382
|
if (typeof match === "object") {
|
|
287
383
|
return {
|
|
@@ -306,7 +402,7 @@ function Make(Bus) {
|
|
|
306
402
|
};
|
|
307
403
|
match = [
|
|
308
404
|
sdl$1,
|
|
309
|
-
resolver$
|
|
405
|
+
resolver$3
|
|
310
406
|
];
|
|
311
407
|
}
|
|
312
408
|
let listResolvers = [[
|
|
@@ -324,7 +420,7 @@ function Make(Bus) {
|
|
|
324
420
|
}
|
|
325
421
|
let itemsResolvers;
|
|
326
422
|
if (subIdField !== undefined) {
|
|
327
|
-
let resolver$
|
|
423
|
+
let resolver$4 = async (_root, args, ctx) => {
|
|
328
424
|
let emptyConn = {
|
|
329
425
|
edges: [],
|
|
330
426
|
pageInfo: {
|
|
@@ -423,22 +519,81 @@ function Make(Bus) {
|
|
|
423
519
|
};
|
|
424
520
|
itemsResolvers = [[
|
|
425
521
|
singleQueryName + "Items",
|
|
426
|
-
resolver$
|
|
522
|
+
resolver$4
|
|
427
523
|
]];
|
|
428
524
|
} else {
|
|
429
525
|
itemsResolvers = [];
|
|
430
526
|
}
|
|
431
|
-
let indexSdlFields = indexes.map(ic =>
|
|
527
|
+
let indexSdlFields = indexes.map(ic => GraphQL_FragmentGenerator$ReventlessCore.deriveIndexQueryField(singleQueryName, ic, returnTypeName + "Connection"));
|
|
528
|
+
let rejectBackwardPaging = args => {
|
|
529
|
+
let given = key => {
|
|
530
|
+
let match = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[key]);
|
|
531
|
+
if (match !== undefined) {
|
|
532
|
+
return match !== null;
|
|
533
|
+
} else {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
if (!(given("last") || given("before"))) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
throw GraphQL_CallerError$ReventlessLocal.badUserInput("Backward pagination (last/before) is not supported on by-index connections; use first/after.");
|
|
541
|
+
};
|
|
542
|
+
let indexConnection = (args, items) => {
|
|
543
|
+
let arg = key => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[key]);
|
|
544
|
+
let after = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(arg("after"), Stdlib_JSON.Decode.string), s => Stdlib_Int.fromString(s, undefined)), -1);
|
|
545
|
+
let first = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(arg("first"), Stdlib_JSON.Decode.float), prim => prim | 0), 50);
|
|
546
|
+
let start = after + 1 | 0;
|
|
547
|
+
let page = items.slice(start, start + first | 0);
|
|
548
|
+
let taken = page.length;
|
|
549
|
+
let edges = page.map((item, i) => Object.fromEntries([
|
|
550
|
+
[
|
|
551
|
+
"node",
|
|
552
|
+
item
|
|
553
|
+
],
|
|
554
|
+
[
|
|
555
|
+
"cursor",
|
|
556
|
+
(start + i | 0).toString()
|
|
557
|
+
]
|
|
558
|
+
]));
|
|
559
|
+
return Object.fromEntries([
|
|
560
|
+
[
|
|
561
|
+
"edges",
|
|
562
|
+
edges
|
|
563
|
+
],
|
|
564
|
+
[
|
|
565
|
+
"pageInfo",
|
|
566
|
+
Object.fromEntries([
|
|
567
|
+
[
|
|
568
|
+
"hasNextPage",
|
|
569
|
+
(start + taken | 0) < items.length
|
|
570
|
+
],
|
|
571
|
+
[
|
|
572
|
+
"hasPreviousPage",
|
|
573
|
+
start > 0
|
|
574
|
+
],
|
|
575
|
+
[
|
|
576
|
+
"startCursor",
|
|
577
|
+
taken > 0 ? start.toString() : null
|
|
578
|
+
],
|
|
579
|
+
[
|
|
580
|
+
"endCursor",
|
|
581
|
+
taken > 0 ? ((start + taken | 0) - 1 | 0).toString() : null
|
|
582
|
+
]
|
|
583
|
+
])
|
|
584
|
+
]
|
|
585
|
+
]);
|
|
586
|
+
};
|
|
432
587
|
let indexResolvers = indexes.map(ic => {
|
|
433
|
-
let
|
|
434
|
-
let
|
|
435
|
-
let filterField = Stdlib_Option.getOr(ic.idField, index);
|
|
588
|
+
let resolverName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(singleQueryName, ic.index);
|
|
589
|
+
let filterField = GraphQL_FragmentGenerator$ReventlessCore.indexKeyField(ic);
|
|
436
590
|
let resolver = async (_root, args, ctx) => {
|
|
437
591
|
let match = await runInterceptor(ctx, args);
|
|
438
592
|
if (typeof match === "object") {
|
|
439
|
-
return [];
|
|
593
|
+
return indexConnection(args, []);
|
|
440
594
|
}
|
|
441
|
-
|
|
595
|
+
rejectBackwardPaging(args);
|
|
596
|
+
let value = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[filterField]), Stdlib_JSON.Decode.string), "");
|
|
442
597
|
let scoped = rows => rows.filter(item => {
|
|
443
598
|
if (ownerAllows(ctx, item)) {
|
|
444
599
|
return retiredAllows(ctx, args, item);
|
|
@@ -448,13 +603,13 @@ function Make(Bus) {
|
|
|
448
603
|
});
|
|
449
604
|
let lookup = Bus.getQueryDbIndexLookup(name);
|
|
450
605
|
if (lookup !== undefined) {
|
|
451
|
-
return scoped(lookup(filterField, value));
|
|
606
|
+
return indexConnection(args, scoped(lookup(filterField, value)));
|
|
452
607
|
}
|
|
453
608
|
let scanAll = Bus.getQueryDbScan(name);
|
|
454
609
|
if (scanAll !== undefined) {
|
|
455
|
-
return scoped(scanAll().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[filterField]), Stdlib_JSON.Decode.string), v => v === value), false)));
|
|
610
|
+
return indexConnection(args, scoped(scanAll().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[filterField]), Stdlib_JSON.Decode.string), v => v === value), false))));
|
|
456
611
|
} else {
|
|
457
|
-
return [];
|
|
612
|
+
return indexConnection(args, []);
|
|
458
613
|
}
|
|
459
614
|
};
|
|
460
615
|
return [
|
|
@@ -462,12 +617,15 @@ function Make(Bus) {
|
|
|
462
617
|
resolver
|
|
463
618
|
];
|
|
464
619
|
});
|
|
465
|
-
let allSdl = [byIdSdl].concat(byIdsSdl).concat(match[0]).concat(itemsSdl).concat(indexSdlFields);
|
|
620
|
+
let allSdl = [byIdSdl].concat(byIdsSdl).concat(refsSdl).concat(match[0]).concat(itemsSdl).concat(indexSdlFields);
|
|
466
621
|
let resolvers = {};
|
|
467
622
|
resolvers[singleQueryName] = byIdResolver;
|
|
468
623
|
Stdlib_Option.forEach(byIdsResolverEntry, param => {
|
|
469
624
|
resolvers[param[0]] = param[1];
|
|
470
625
|
});
|
|
626
|
+
Stdlib_Option.forEach(refsResolverEntry, param => {
|
|
627
|
+
resolvers[param[0]] = param[1];
|
|
628
|
+
});
|
|
471
629
|
listResolvers.forEach(param => {
|
|
472
630
|
resolvers[param[0]] = param[1];
|
|
473
631
|
});
|
package/tests/UiHintsTest.res
CHANGED
|
@@ -103,30 +103,54 @@ describe("UiHints.emit", () => {
|
|
|
103
103
|
// point, while mid-session it is almost always an editor mid-save, and killing a
|
|
104
104
|
// running dev server over a keystroke is worse than the restart this replaces.
|
|
105
105
|
|
|
106
|
-
//
|
|
106
|
+
// The edit is repeated, and that is not belt-and-braces.
|
|
107
|
+
//
|
|
108
|
+
// `fs.watch` does not promise that a write landing near the watcher's creation
|
|
109
|
+
// is reported. The FSEvents stream behind it on macOS is armed asynchronously,
|
|
110
|
+
// and a write that gets there first is not reported late — it is not reported at
|
|
111
|
+
// all. Measured against this very module: once the stream is up the edit is seen
|
|
112
|
+
// in about 10ms, and when it is not, the same edit produces no event in four
|
|
113
|
+
// seconds, with which of the two happens varying by how many watchers the
|
|
114
|
+
// process has already opened rather than by anything the test does.
|
|
115
|
+
//
|
|
116
|
+
// So a single edit tests the arming, not the reload. Repeating it on an interval
|
|
117
|
+
// far longer than a reload takes — 50ms of debounce plus a file copy — leaves the
|
|
118
|
+
// case where the first write lands identical to what it always was, and lets the
|
|
119
|
+
// case where it is dropped recover on the next one instead of reporting that hot
|
|
120
|
+
// reload is broken. Re-writing the same bytes is what a developer saving twice
|
|
121
|
+
// does, so nothing here asserts less than it did.
|
|
122
|
+
let retryEvery = 1000
|
|
123
|
+
|
|
124
|
+
// All three exits close the watcher AND clear both timers. A watcher is `unref`ed
|
|
107
125
|
// and would not hold the run open on its own, but a live 4-second timer would —
|
|
108
126
|
// and "Jest did not exit" on a suite that passed is exactly the noise that
|
|
109
127
|
// teaches a reader to ignore it.
|
|
110
|
-
let waitForReload = (~timeoutMs: int=4000, ~
|
|
128
|
+
let waitForReload = (~timeoutMs: int=4000, ~edit: unit => unit, ~uiHintsFile, ~dir) =>
|
|
111
129
|
Promise.make((resolve, _) => {
|
|
112
130
|
let fired = ref(0)
|
|
113
131
|
let watcher = ref(None)
|
|
114
132
|
let deadline = ref(None)
|
|
133
|
+
let retry = ref(None)
|
|
115
134
|
// Resolves with a count rather than rejecting on the deadline: "nothing was
|
|
116
135
|
// re-served" is the expected answer in half these cases, and a rejection
|
|
117
136
|
// would make the assertion read as an infrastructure failure.
|
|
118
137
|
let finish = () => {
|
|
119
138
|
watcher.contents->Option.forEach(NodeFs.watcherClose)
|
|
120
139
|
deadline.contents->Option.forEach(clearTimeout)
|
|
140
|
+
retry.contents->Option.forEach(clearTimeout)
|
|
121
141
|
resolve(fired.contents)
|
|
122
142
|
}
|
|
143
|
+
let rec editUntilSeen = () => {
|
|
144
|
+
edit()
|
|
145
|
+
retry := Some(setTimeout(editUntilSeen, retryEvery))
|
|
146
|
+
}
|
|
123
147
|
watcher :=
|
|
124
148
|
UiHints.watch(~uiHintsFile, ~dir, ~onReload=() => {
|
|
125
149
|
fired := fired.contents + 1
|
|
126
150
|
finish()
|
|
127
151
|
})
|
|
128
152
|
deadline := Some(setTimeout(finish, timeoutMs))
|
|
129
|
-
|
|
153
|
+
editUntilSeen()
|
|
130
154
|
})
|
|
131
155
|
|
|
132
156
|
describe("UiHints.watch", () => {
|
|
@@ -147,7 +171,7 @@ describe("UiHints.watch", () => {
|
|
|
147
171
|
let fired = await waitForReload(
|
|
148
172
|
~uiHintsFile=Some(path),
|
|
149
173
|
~dir,
|
|
150
|
-
~
|
|
174
|
+
~edit=() => NodeFs.writeFileSync(path, edited),
|
|
151
175
|
)
|
|
152
176
|
expect((fired > 0, served(dir)))->toEqual((true, edited))
|
|
153
177
|
})
|
|
@@ -163,7 +187,7 @@ describe("UiHints.watch", () => {
|
|
|
163
187
|
~timeoutMs=1500,
|
|
164
188
|
~uiHintsFile=Some(path),
|
|
165
189
|
~dir,
|
|
166
|
-
~
|
|
190
|
+
~edit=() => NodeFs.writeFileSync(path, `{"Catalog": {"views":`),
|
|
167
191
|
)
|
|
168
192
|
expect((fired, served(dir)))->toEqual((0, declared))
|
|
169
193
|
})
|
|
@@ -178,7 +202,10 @@ describe("UiHints.watch", () => {
|
|
|
178
202
|
let fired = await waitForReload(
|
|
179
203
|
~uiHintsFile=Some(path),
|
|
180
204
|
~dir,
|
|
181
|
-
|
|
205
|
+
// Both halves of the save, so a repeat of this edit is the same story told
|
|
206
|
+
// again and still ends on the whole file — never a truncated write left
|
|
207
|
+
// standing after a complete one.
|
|
208
|
+
~edit=() => {
|
|
182
209
|
NodeFs.writeFileSync(path, `{"Catalog": {"views":`)
|
|
183
210
|
let _ = setTimeout(() => NodeFs.writeFileSync(path, whole), 200)
|
|
184
211
|
},
|
|
@@ -88,7 +88,7 @@ globalThis.describe("UiHints.emit", () => {
|
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
function waitForReload(timeoutMsOpt,
|
|
91
|
+
function waitForReload(timeoutMsOpt, edit, uiHintsFile, dir) {
|
|
92
92
|
let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 4000;
|
|
93
93
|
return new Promise((resolve, param) => {
|
|
94
94
|
let fired = {
|
|
@@ -100,6 +100,9 @@ function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
|
|
|
100
100
|
let deadline = {
|
|
101
101
|
contents: undefined
|
|
102
102
|
};
|
|
103
|
+
let retry = {
|
|
104
|
+
contents: undefined
|
|
105
|
+
};
|
|
103
106
|
let finish = () => {
|
|
104
107
|
Stdlib_Option.forEach(watcher.contents, prim => {
|
|
105
108
|
prim.close();
|
|
@@ -107,14 +110,21 @@ function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
|
|
|
107
110
|
Stdlib_Option.forEach(deadline.contents, prim => {
|
|
108
111
|
clearTimeout(prim);
|
|
109
112
|
});
|
|
113
|
+
Stdlib_Option.forEach(retry.contents, prim => {
|
|
114
|
+
clearTimeout(prim);
|
|
115
|
+
});
|
|
110
116
|
resolve(fired.contents);
|
|
111
117
|
};
|
|
118
|
+
let editUntilSeen = () => {
|
|
119
|
+
edit();
|
|
120
|
+
retry.contents = Primitive_option.some(setTimeout(editUntilSeen, 1000));
|
|
121
|
+
};
|
|
112
122
|
watcher.contents = UiHints$ReventlessLocal.watch(uiHintsFile, dir, () => {
|
|
113
123
|
fired.contents = fired.contents + 1 | 0;
|
|
114
124
|
finish();
|
|
115
125
|
});
|
|
116
126
|
deadline.contents = Primitive_option.some(setTimeout(finish, timeoutMs));
|
|
117
|
-
|
|
127
|
+
editUntilSeen();
|
|
118
128
|
});
|
|
119
129
|
}
|
|
120
130
|
|
|
@@ -178,6 +188,8 @@ globalThis.describe("UiHints.watch", () => {
|
|
|
178
188
|
});
|
|
179
189
|
});
|
|
180
190
|
|
|
191
|
+
let retryEvery = 1000;
|
|
192
|
+
|
|
181
193
|
export {
|
|
182
194
|
shipped,
|
|
183
195
|
declared,
|
|
@@ -187,6 +199,7 @@ export {
|
|
|
187
199
|
served,
|
|
188
200
|
baselinePath,
|
|
189
201
|
threw,
|
|
202
|
+
retryEvery,
|
|
190
203
|
waitForReload,
|
|
191
204
|
}
|
|
192
205
|
/* Not a pure module */
|
|
@@ -271,7 +271,7 @@ describe("GraphQL_SchemaInspector", () => {
|
|
|
271
271
|
expect(result.typeDef->Option.isSome)->toBe(true)
|
|
272
272
|
let typeDef = result.typeDef->Option.getOrThrow
|
|
273
273
|
expect(typeDef->String.includes("type CatalogProduct"))->toBe(true)
|
|
274
|
-
expect(result.singleQuery->String.includes("Catalog_Product(id: ID
|
|
274
|
+
expect(result.singleQuery->String.includes("Catalog_Product(id: ID!, includeRetired: Boolean)"))->toBe(true)
|
|
275
275
|
expect(result.singleQuery->String.includes("CatalogProduct"))->toBe(true)
|
|
276
276
|
expect(result.listQuery->Option.isSome)->toBe(true)
|
|
277
277
|
let listQ = result.listQuery->Option.getOrThrow
|
|
@@ -298,7 +298,7 @@ describe("GraphQL_SchemaInspector", () => {
|
|
|
298
298
|
)
|
|
299
299
|
let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
|
|
300
300
|
let sdl = inspection.sdlPreview
|
|
301
|
-
expect(sdl->String.includes("RM_Product(id: ID
|
|
301
|
+
expect(sdl->String.includes("RM_Product(id: ID!, includeRetired: Boolean): RMProduct"))->toBe(true)
|
|
302
302
|
expect(sdl->String.includes("type RMProduct"))->toBe(true)
|
|
303
303
|
// The type should have an injected id: ID! field (first field in the type)
|
|
304
304
|
let typeLines = sdl->String.split("\n")
|
|
@@ -357,7 +357,7 @@ describe("GraphQL_SchemaInspector", () => {
|
|
|
357
357
|
)
|
|
358
358
|
let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
|
|
359
359
|
let sdl = inspection.sdlPreview
|
|
360
|
-
expect(sdl->String.includes("Default_Thing(id: ID
|
|
360
|
+
expect(sdl->String.includes("Default_Thing(id: ID!, includeRetired: Boolean): DefaultThing"))->toBe(true)
|
|
361
361
|
})
|
|
362
362
|
})
|
|
363
363
|
|
|
@@ -542,13 +542,15 @@ describe("GraphQL_SchemaInspector", () => {
|
|
|
542
542
|
],
|
|
543
543
|
)
|
|
544
544
|
let inspection = ReventlessCore.GraphQL_SchemaInspector.inspectFragment(fragment)
|
|
545
|
-
// TestState, TestStateEdge, TestStateConnection, TestStateFilter (
|
|
545
|
+
// TestState, TestStateEdge, TestStateConnection, TestStateFilter (connection filter)
|
|
546
|
+
// + TestStateRef (the reference door's projection)
|
|
546
547
|
// + CommandResult union + CommandAccepted + CommandRejected + CommandPending
|
|
547
548
|
// (auto-injected whenever the fragment emits any mutation field).
|
|
548
|
-
expect(inspection.types->Array.length)->toBe(
|
|
549
|
+
expect(inspection.types->Array.length)->toBe(9)
|
|
549
550
|
expect(inspection.mutations->Array.length)->toBe(1)
|
|
550
|
-
// single Test_State(id), list Test_States(...),
|
|
551
|
-
|
|
551
|
+
// single Test_State(id), list Test_States(...), Test_StatesByIds(ids: [String!]!)
|
|
552
|
+
// and Test_StatesRefs(ids: [ID!]!) — the by-ids read projected to a reference.
|
|
553
|
+
expect(inspection.queries->Array.length)->toBe(4)
|
|
552
554
|
expect(inspection.sdlPreview->String.includes("type TestState"))->toBe(true)
|
|
553
555
|
expect(inspection.sdlPreview->String.includes("type TestStateEdge"))->toBe(true)
|
|
554
556
|
expect(inspection.sdlPreview->String.includes("type TestStateConnection"))->toBe(true)
|
|
@@ -239,7 +239,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
|
|
|
239
239
|
globalThis.expect(Stdlib_Option.isSome(result.typeDef)).toBe(true);
|
|
240
240
|
let typeDef = Stdlib_Option.getOrThrow(result.typeDef, undefined);
|
|
241
241
|
globalThis.expect(typeDef.includes("type CatalogProduct")).toBe(true);
|
|
242
|
-
globalThis.expect(result.singleQuery.includes("Catalog_Product(id: ID
|
|
242
|
+
globalThis.expect(result.singleQuery.includes("Catalog_Product(id: ID!, includeRetired: Boolean)")).toBe(true);
|
|
243
243
|
globalThis.expect(result.singleQuery.includes("CatalogProduct")).toBe(true);
|
|
244
244
|
globalThis.expect(Stdlib_Option.isSome(result.listQuery)).toBe(true);
|
|
245
245
|
let listQ = Stdlib_Option.getOrThrow(result.listQuery, undefined);
|
|
@@ -258,7 +258,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
|
|
|
258
258
|
}]);
|
|
259
259
|
let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
|
|
260
260
|
let sdl = inspection.sdlPreview;
|
|
261
|
-
globalThis.expect(sdl.includes("RM_Product(id: ID
|
|
261
|
+
globalThis.expect(sdl.includes("RM_Product(id: ID!, includeRetired: Boolean): RMProduct")).toBe(true);
|
|
262
262
|
globalThis.expect(sdl.includes("type RMProduct")).toBe(true);
|
|
263
263
|
let typeLines = sdl.split("\n");
|
|
264
264
|
let idFieldInType = typeLines.some(line => {
|
|
@@ -302,7 +302,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
|
|
|
302
302
|
}]);
|
|
303
303
|
let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
|
|
304
304
|
let sdl = inspection.sdlPreview;
|
|
305
|
-
globalThis.expect(sdl.includes("Default_Thing(id: ID
|
|
305
|
+
globalThis.expect(sdl.includes("Default_Thing(id: ID!, includeRetired: Boolean): DefaultThing")).toBe(true);
|
|
306
306
|
});
|
|
307
307
|
});
|
|
308
308
|
globalThis.describe("Relay compliance", () => {
|
|
@@ -424,9 +424,9 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
|
|
|
424
424
|
authorization: undefined
|
|
425
425
|
}]);
|
|
426
426
|
let inspection = GraphQL_SchemaInspector$ReventlessCore.inspectFragment(fragment);
|
|
427
|
-
globalThis.expect(inspection.types.length).toBe(
|
|
427
|
+
globalThis.expect(inspection.types.length).toBe(9);
|
|
428
428
|
globalThis.expect(inspection.mutations.length).toBe(1);
|
|
429
|
-
globalThis.expect(inspection.queries.length).toBe(
|
|
429
|
+
globalThis.expect(inspection.queries.length).toBe(4);
|
|
430
430
|
globalThis.expect(inspection.sdlPreview.includes("type TestState")).toBe(true);
|
|
431
431
|
globalThis.expect(inspection.sdlPreview.includes("type TestStateEdge")).toBe(true);
|
|
432
432
|
globalThis.expect(inspection.sdlPreview.includes("type TestStateConnection")).toBe(true);
|
|
@@ -102,7 +102,7 @@ let pageInfoString = (response: JSON.t, key: string): option<string> =>
|
|
|
102
102
|
// Per-test fixture — fresh Bus + storage + registry + resolver
|
|
103
103
|
// ─────────────────────────────────────────────────────────────
|
|
104
104
|
|
|
105
|
-
let buildFixture = async (~name: string) => {
|
|
105
|
+
let buildFixture = async (~name: string, ~indexes: array<Reventless.ReadModel.indexConfig>=[]) => {
|
|
106
106
|
module Bus = LocalBus.Make()
|
|
107
107
|
module Storage = LocalQueryDbStorage.Make(Bus)
|
|
108
108
|
module Resolvers = QueryDbResolvers_GraphQL.Make(Bus)
|
|
@@ -151,7 +151,7 @@ let buildFixture = async (~name: string) => {
|
|
|
151
151
|
~api=(),
|
|
152
152
|
~apiRole=(),
|
|
153
153
|
~dataSourceName=""->Pulumi.Output.make,
|
|
154
|
-
~indexes
|
|
154
|
+
~indexes,
|
|
155
155
|
~subIdField=None,
|
|
156
156
|
~idResolverConfigs=[],
|
|
157
157
|
~idsResolverConfigs=[],
|
|
@@ -440,3 +440,83 @@ describe("QueryDb list resolver — SQLite push-down path", () => {
|
|
|
440
440
|
})
|
|
441
441
|
)
|
|
442
442
|
})
|
|
443
|
+
|
|
444
|
+
// The by-index door answers a Relay connection, keyed on the index's column.
|
|
445
|
+
// Both halves used to be wrong here and in the opposite direction on AppSync:
|
|
446
|
+
// this backend promised `[String]` and returned whole rows, so every call failed
|
|
447
|
+
// to serialise before a caller could reach the rows at all.
|
|
448
|
+
describe("QueryDb by-index resolver", () => {
|
|
449
|
+
let statusIndex: array<Reventless.ReadModel.indexConfig> = [
|
|
450
|
+
{index: "status", type_: "S", projectionType: ALL},
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
let indexResolver = async (~name) => {
|
|
454
|
+
let _ = await buildFixture(~name, ~indexes=statusIndex)
|
|
455
|
+
switch DomainGraphQL_Server.getQueryResolver(name ++ "ByStatus") {
|
|
456
|
+
| Some(r) => r
|
|
457
|
+
| None => JsError.throwWithMessage("index resolver not registered: " ++ name ++ "ByStatus")
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
beforeEach(() => {
|
|
462
|
+
DomainGraphQL_Server.reset()
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
testPromise("answers the rows carrying the index value, as edges", async () => {
|
|
466
|
+
let resolver = await indexResolver(~name="IdxA")
|
|
467
|
+
let response = await resolver(JSON.Encode.null, argsOf([("status", strJson("active"))]), emptyCtx)
|
|
468
|
+
let edges = getEdges(response)
|
|
469
|
+
expect(edges->Array.length)->toBe(3)
|
|
470
|
+
expect(edgeNodeField(edges->Array.getUnsafe(0), "productId"))->toBe("p-1")
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
testPromise("pages forward on first/after like every other connection", async () => {
|
|
474
|
+
let resolver = await indexResolver(~name="IdxB")
|
|
475
|
+
let page1 =
|
|
476
|
+
await resolver(
|
|
477
|
+
JSON.Encode.null,
|
|
478
|
+
argsOf([("status", strJson("active")), ("first", numJson(2))]),
|
|
479
|
+
emptyCtx,
|
|
480
|
+
)
|
|
481
|
+
expect(getEdges(page1)->Array.length)->toBe(2)
|
|
482
|
+
expect(pageInfoBool(page1, "hasNextPage"))->toBe(true)
|
|
483
|
+
|
|
484
|
+
let page2 =
|
|
485
|
+
await resolver(
|
|
486
|
+
JSON.Encode.null,
|
|
487
|
+
argsOf([
|
|
488
|
+
("status", strJson("active")),
|
|
489
|
+
("first", numJson(2)),
|
|
490
|
+
("after", strJson(pageInfoString(page1, "endCursor")->Option.getOr(""))),
|
|
491
|
+
]),
|
|
492
|
+
emptyCtx,
|
|
493
|
+
)
|
|
494
|
+
let edges2 = getEdges(page2)
|
|
495
|
+
expect(edges2->Array.length)->toBe(1)
|
|
496
|
+
expect(edgeNodeField(edges2->Array.getUnsafe(0), "productId"))->toBe("p-4")
|
|
497
|
+
expect(pageInfoBool(page2, "hasNextPage"))->toBe(false)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
// Refused rather than ignored, and refused here because AppSync cannot do it:
|
|
501
|
+
// a door that paged backward on one backend and quietly returned the forward
|
|
502
|
+
// page on the other is the divergence this whole field was rebuilt to remove.
|
|
503
|
+
testPromise("refuses backward paging instead of answering forward", async () => {
|
|
504
|
+
let resolver = await indexResolver(~name="IdxC")
|
|
505
|
+
let outcome = try {
|
|
506
|
+
let _ =
|
|
507
|
+
await resolver(
|
|
508
|
+
JSON.Encode.null,
|
|
509
|
+
argsOf([("status", strJson("active")), ("last", numJson(2))]),
|
|
510
|
+
emptyCtx,
|
|
511
|
+
)
|
|
512
|
+
Ok()
|
|
513
|
+
} catch {
|
|
514
|
+
| e => Error(e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr(""))
|
|
515
|
+
}
|
|
516
|
+
switch outcome {
|
|
517
|
+
| Ok() => expect("no error")->toBe("a BAD_USER_INPUT refusal")
|
|
518
|
+
| Error(message) =>
|
|
519
|
+
expect(message->String.includes("Backward pagination (last/before) is not supported"))->toBe(true)
|
|
520
|
+
}
|
|
521
|
+
})
|
|
522
|
+
})
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
4
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
5
6
|
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
6
7
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
8
|
import * as Pulumi from "@pulumi/pulumi";
|
|
8
9
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
9
10
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
10
11
|
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
12
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
13
|
import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
|
|
12
14
|
import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
|
|
13
15
|
import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
|
|
@@ -112,7 +114,8 @@ function pageInfoString(response, key) {
|
|
|
112
114
|
return getString(pageInfo(response), key);
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
async function buildFixture(name) {
|
|
117
|
+
async function buildFixture(name, indexesOpt) {
|
|
118
|
+
let indexes = indexesOpt !== undefined ? indexesOpt : [];
|
|
116
119
|
let Bus = LocalBus$ReventlessLocal.Make({});
|
|
117
120
|
let Storage = LocalQueryDbStorage$ReventlessLocal.Make(Bus);
|
|
118
121
|
let Resolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
|
|
@@ -151,7 +154,7 @@ async function buildFixture(name) {
|
|
|
151
154
|
Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name] = rowStateSchemaWithAnnotations;
|
|
152
155
|
let queryDb = Maker.make(undefined, undefined, undefined, undefined, undefined);
|
|
153
156
|
let ops = await TestRunner$ReventlessLocal.resolve(Component$ReventlessCore.operations(queryDb));
|
|
154
|
-
Resolvers.make(name, undefined, undefined, Pulumi.output(""),
|
|
157
|
+
Resolvers.make(name, undefined, undefined, Pulumi.output(""), indexes, undefined, [], [], "AllowAuthenticated", {});
|
|
155
158
|
let r = DomainGraphQL_Server$ReventlessLocal.getQueryResolver(listFieldName);
|
|
156
159
|
let resolver = r !== undefined ? r : Stdlib_JsError.throwWithMessage("resolver not registered: " + listFieldName);
|
|
157
160
|
let rows = [
|
|
@@ -199,7 +202,7 @@ async function buildFixture(name) {
|
|
|
199
202
|
globalThis.describe("QueryDb list resolver — keyset pagination", () => {
|
|
200
203
|
globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.reset());
|
|
201
204
|
globalThis.test("first: 2 over a 5-row fixture returns 2 edges and hasNextPage = true", async () => {
|
|
202
|
-
let match = await buildFixture("PageA");
|
|
205
|
+
let match = await buildFixture("PageA", undefined);
|
|
203
206
|
let response = await match[0](null, Object.fromEntries([[
|
|
204
207
|
"first",
|
|
205
208
|
2
|
|
@@ -212,7 +215,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
|
|
|
212
215
|
globalThis.expect(edgeNodeField(edges[1], "productId")).toBe("p-2");
|
|
213
216
|
});
|
|
214
217
|
globalThis.test("after: <endCursor> walks forward without overlap", async () => {
|
|
215
|
-
let match = await buildFixture("PageB");
|
|
218
|
+
let match = await buildFixture("PageB", undefined);
|
|
216
219
|
let resolver = match[0];
|
|
217
220
|
let page1 = await resolver(null, Object.fromEntries([[
|
|
218
221
|
"first",
|
|
@@ -251,7 +254,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
|
|
|
251
254
|
globalThis.expect(pageInfoBool(page3, "hasNextPage")).toBe(false);
|
|
252
255
|
});
|
|
253
256
|
globalThis.test("last + before walks backward and flips hasPreviousPage", async () => {
|
|
254
|
-
let match = await buildFixture("PageC");
|
|
257
|
+
let match = await buildFixture("PageC", undefined);
|
|
255
258
|
let resolver = match[0];
|
|
256
259
|
let page1 = await resolver(null, Object.fromEntries([[
|
|
257
260
|
"first",
|
|
@@ -314,7 +317,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
|
|
|
314
317
|
globalThis.expect(pageInfoBool(firstPage, "hasPreviousPage")).toBe(false);
|
|
315
318
|
});
|
|
316
319
|
globalThis.test("filter.<field>Eq + first / after paginates only the narrowed subset", async () => {
|
|
317
|
-
let match = await buildFixture("PageD");
|
|
320
|
+
let match = await buildFixture("PageD", undefined);
|
|
318
321
|
let resolver = match[0];
|
|
319
322
|
let activeFilter = Object.fromEntries([[
|
|
320
323
|
"statusEq",
|
|
@@ -356,7 +359,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
|
|
|
356
359
|
globalThis.expect(pageInfoBool(page2, "hasNextPage")).toBe(false);
|
|
357
360
|
});
|
|
358
361
|
globalThis.test("orderBy DESC + first / after paginates the reverse-sorted view", async () => {
|
|
359
|
-
let match = await buildFixture("PageE");
|
|
362
|
+
let match = await buildFixture("PageE", undefined);
|
|
360
363
|
let resolver = match[0];
|
|
361
364
|
let orderBy = Object.fromEntries([
|
|
362
365
|
[
|
|
@@ -404,7 +407,7 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
|
|
|
404
407
|
globalThis.expect(edgeNodeField(edges2[1], "name")).toBe("Bravo");
|
|
405
408
|
});
|
|
406
409
|
globalThis.test("bare request (no first / after) returns a single bounded page", async () => {
|
|
407
|
-
let match = await buildFixture("PageF");
|
|
410
|
+
let match = await buildFixture("PageF", undefined);
|
|
408
411
|
let response = await match[0](null, Object.fromEntries([]), emptyCtx);
|
|
409
412
|
let edges = getEdges(response);
|
|
410
413
|
globalThis.expect(edges.length).toBe(5);
|
|
@@ -424,7 +427,7 @@ globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
|
|
|
424
427
|
return r;
|
|
425
428
|
};
|
|
426
429
|
globalThis.test("first:2 then after walks forward without overlap", () => withSqlite(async () => {
|
|
427
|
-
let match = await buildFixture("SqlPageA");
|
|
430
|
+
let match = await buildFixture("SqlPageA", undefined);
|
|
428
431
|
let resolver = match[0];
|
|
429
432
|
let page1 = await resolver(null, Object.fromEntries([[
|
|
430
433
|
"first",
|
|
@@ -452,7 +455,7 @@ globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
|
|
|
452
455
|
globalThis.expect(edgeNodeField(edges2[1], "productId")).toBe("p-4");
|
|
453
456
|
}));
|
|
454
457
|
globalThis.test("orderBy name DESC + first:2 returns the reverse-sorted head", () => withSqlite(async () => {
|
|
455
|
-
let match = await buildFixture("SqlPageB");
|
|
458
|
+
let match = await buildFixture("SqlPageB", undefined);
|
|
456
459
|
let orderBy = Object.fromEntries([
|
|
457
460
|
[
|
|
458
461
|
"field",
|
|
@@ -479,7 +482,7 @@ globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
|
|
|
479
482
|
globalThis.expect(edgeNodeField(edges1[1], "name")).toBe("Delta");
|
|
480
483
|
}));
|
|
481
484
|
globalThis.test("statusEq active narrows the pushed page", () => withSqlite(async () => {
|
|
482
|
-
let match = await buildFixture("SqlPageC");
|
|
485
|
+
let match = await buildFixture("SqlPageC", undefined);
|
|
483
486
|
let activeFilter = Object.fromEntries([[
|
|
484
487
|
"statusEq",
|
|
485
488
|
"active"
|
|
@@ -495,6 +498,98 @@ globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
|
|
|
495
498
|
}));
|
|
496
499
|
});
|
|
497
500
|
|
|
501
|
+
globalThis.describe("QueryDb by-index resolver", () => {
|
|
502
|
+
let statusIndex = [{
|
|
503
|
+
index: "status",
|
|
504
|
+
type_: "S",
|
|
505
|
+
projectionType: "ALL"
|
|
506
|
+
}];
|
|
507
|
+
let indexResolver = async name => {
|
|
508
|
+
await buildFixture(name, statusIndex);
|
|
509
|
+
let r = DomainGraphQL_Server$ReventlessLocal.getQueryResolver(name + "ByStatus");
|
|
510
|
+
if (r !== undefined) {
|
|
511
|
+
return r;
|
|
512
|
+
} else {
|
|
513
|
+
return Stdlib_JsError.throwWithMessage("index resolver not registered: " + name + "ByStatus");
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.reset());
|
|
517
|
+
globalThis.test("answers the rows carrying the index value, as edges", async () => {
|
|
518
|
+
let resolver = await indexResolver("IdxA");
|
|
519
|
+
let response = await resolver(null, Object.fromEntries([[
|
|
520
|
+
"status",
|
|
521
|
+
"active"
|
|
522
|
+
]]), emptyCtx);
|
|
523
|
+
let edges = getEdges(response);
|
|
524
|
+
globalThis.expect(edges.length).toBe(3);
|
|
525
|
+
globalThis.expect(edgeNodeField(edges[0], "productId")).toBe("p-1");
|
|
526
|
+
});
|
|
527
|
+
globalThis.test("pages forward on first/after like every other connection", async () => {
|
|
528
|
+
let resolver = await indexResolver("IdxB");
|
|
529
|
+
let page1 = await resolver(null, Object.fromEntries([
|
|
530
|
+
[
|
|
531
|
+
"status",
|
|
532
|
+
"active"
|
|
533
|
+
],
|
|
534
|
+
[
|
|
535
|
+
"first",
|
|
536
|
+
2
|
|
537
|
+
]
|
|
538
|
+
]), emptyCtx);
|
|
539
|
+
globalThis.expect(getEdges(page1).length).toBe(2);
|
|
540
|
+
globalThis.expect(pageInfoBool(page1, "hasNextPage")).toBe(true);
|
|
541
|
+
let page2 = await resolver(null, Object.fromEntries([
|
|
542
|
+
[
|
|
543
|
+
"status",
|
|
544
|
+
"active"
|
|
545
|
+
],
|
|
546
|
+
[
|
|
547
|
+
"first",
|
|
548
|
+
2
|
|
549
|
+
],
|
|
550
|
+
[
|
|
551
|
+
"after",
|
|
552
|
+
Stdlib_Option.getOr(getString(pageInfo(page1), "endCursor"), "")
|
|
553
|
+
]
|
|
554
|
+
]), emptyCtx);
|
|
555
|
+
let edges2 = getEdges(page2);
|
|
556
|
+
globalThis.expect(edges2.length).toBe(1);
|
|
557
|
+
globalThis.expect(edgeNodeField(edges2[0], "productId")).toBe("p-4");
|
|
558
|
+
globalThis.expect(pageInfoBool(page2, "hasNextPage")).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
globalThis.test("refuses backward paging instead of answering forward", async () => {
|
|
561
|
+
let resolver = await indexResolver("IdxC");
|
|
562
|
+
let outcome;
|
|
563
|
+
try {
|
|
564
|
+
await resolver(null, Object.fromEntries([
|
|
565
|
+
[
|
|
566
|
+
"status",
|
|
567
|
+
"active"
|
|
568
|
+
],
|
|
569
|
+
[
|
|
570
|
+
"last",
|
|
571
|
+
2
|
|
572
|
+
]
|
|
573
|
+
]), emptyCtx);
|
|
574
|
+
outcome = {
|
|
575
|
+
TAG: "Ok",
|
|
576
|
+
_0: undefined
|
|
577
|
+
};
|
|
578
|
+
} catch (raw_e) {
|
|
579
|
+
let e = Primitive_exceptions.internalToException(raw_e);
|
|
580
|
+
outcome = {
|
|
581
|
+
TAG: "Error",
|
|
582
|
+
_0: Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "")
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
if (outcome.TAG === "Ok") {
|
|
586
|
+
globalThis.expect("no error").toBe("a BAD_USER_INPUT refusal");
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
globalThis.expect(outcome._0.includes("Backward pagination (last/before) is not supported")).toBe(true);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
498
593
|
export {
|
|
499
594
|
rowStateSchema,
|
|
500
595
|
rowStateSchemaWithAnnotations,
|