@reventlessdev/reventless-local 3.0.0-alpha.115 → 3.0.0-alpha.117
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 +27 -0
- package/package.json +8 -8
- 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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Parity harness for the SQLite connection-list push-down (plan B4).
|
|
2
|
+
//
|
|
3
|
+
// The SQLite `registerQueryDbListPage` builds json_extract predicates + ORDER BY
|
|
4
|
+
// … LIMIT to serve a page without materialising the whole read model. Its result
|
|
5
|
+
// MUST equal `QueryDbListQuery.run` (the shared spec the in-memory backend and
|
|
6
|
+
// the resolver fallback use) for every shape it claims to handle. This runs a
|
|
7
|
+
// matrix of (data, args) through BOTH the push-down and the spec-over-full-scan
|
|
8
|
+
// and asserts identical edges + pageInfo — and asserts the push-down returns None
|
|
9
|
+
// (→ fallback) for the shapes it deliberately doesn't reproduce in SQL.
|
|
10
|
+
|
|
11
|
+
open JestGlobals
|
|
12
|
+
|
|
13
|
+
let _ = TestRunner.setup()
|
|
14
|
+
let opts: Pulumi.CustomResourceOptions.t = {}
|
|
15
|
+
|
|
16
|
+
let capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability = {
|
|
17
|
+
filterFields: [
|
|
18
|
+
{name: "status", gqlType: "String", range: false},
|
|
19
|
+
{name: "qty", gqlType: "Int", range: true},
|
|
20
|
+
],
|
|
21
|
+
sortFields: ["name", "qty", "status"],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let mk = (id, status, name, qty) => {
|
|
25
|
+
let o = Dict.make()
|
|
26
|
+
o->Dict.set("id", JSON.Encode.string(id))
|
|
27
|
+
o->Dict.set("status", JSON.Encode.string(status))
|
|
28
|
+
o->Dict.set("name", JSON.Encode.string(name))
|
|
29
|
+
o->Dict.set("qty", JSON.Encode.int(qty))
|
|
30
|
+
JSON.Encode.object(o)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Names differ from id-order; status has duplicates (tiebreak); qty exercises the
|
|
34
|
+
// numeric-field-as-string comparison the push-down must match.
|
|
35
|
+
let rows = [
|
|
36
|
+
("p-1", "active", "Charlie", 3),
|
|
37
|
+
("p-2", "active", "Alpha", 1),
|
|
38
|
+
("p-3", "inactive", "Echo", 5),
|
|
39
|
+
("p-4", "active", "Bravo", 2),
|
|
40
|
+
("p-5", "inactive", "Delta", 4),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
type setup = {
|
|
44
|
+
listPage: (
|
|
45
|
+
~argsDict: dict<JSON.t>,
|
|
46
|
+
~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
|
|
47
|
+
~labelField: string,
|
|
48
|
+
) => option<JSON.t>,
|
|
49
|
+
fullScan: unit => array<JSON.t>,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let build = async (): setup => {
|
|
53
|
+
module TestBus = LocalBus.Make()
|
|
54
|
+
module DbProvider = {
|
|
55
|
+
let db = SqliteDriver.openDb(~path=":memory:")
|
|
56
|
+
}
|
|
57
|
+
module Storage = QueryDbStorage_Sqlite.Make(TestBus, DbProvider)
|
|
58
|
+
let s = Storage.make(~name="items", ~indexes=[], ~api=(), ~apiRole=(), ~opts)
|
|
59
|
+
let ops = await s.operations->TestRunner.resolve
|
|
60
|
+
for i in 0 to rows->Array.length - 1 {
|
|
61
|
+
let (id, status, name, qty) = rows->Array.getUnsafe(i)
|
|
62
|
+
let _ = await ops.save(id, mk(id, status, name, qty), ReventlessCore.QueryDb.Any, None)
|
|
63
|
+
}
|
|
64
|
+
{
|
|
65
|
+
listPage: TestBus.getQueryDbListPage("items")->Option.getOrThrow,
|
|
66
|
+
fullScan: TestBus.getQueryDbScan("items")->Option.getOrThrow,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── normalise a connection to a comparable value ────────────────────────────
|
|
71
|
+
type normEdge = {id: string, cursor: string}
|
|
72
|
+
type normConn = {
|
|
73
|
+
edges: array<normEdge>,
|
|
74
|
+
hasNext: bool,
|
|
75
|
+
hasPrev: bool,
|
|
76
|
+
startCursor: option<string>,
|
|
77
|
+
endCursor: option<string>,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let field = (j, k) => j->JSON.Decode.object->Option.flatMap(d => d->Dict.get(k))
|
|
81
|
+
let str = (j, k) => j->field(k)->Option.flatMap(JSON.Decode.string)
|
|
82
|
+
|
|
83
|
+
let norm = (conn: JSON.t): normConn => {
|
|
84
|
+
let edges =
|
|
85
|
+
conn
|
|
86
|
+
->field("edges")
|
|
87
|
+
->Option.flatMap(JSON.Decode.array)
|
|
88
|
+
->Option.getOr([])
|
|
89
|
+
->Array.map(e => {
|
|
90
|
+
id: e->field("node")->Option.flatMap(n => n->str("id"))->Option.getOr(""),
|
|
91
|
+
cursor: e->str("cursor")->Option.getOr(""),
|
|
92
|
+
})
|
|
93
|
+
let pi = conn->field("pageInfo")->Option.getOr(JSON.Encode.null)
|
|
94
|
+
{
|
|
95
|
+
edges,
|
|
96
|
+
hasNext: pi->field("hasNextPage")->Option.flatMap(JSON.Decode.bool)->Option.getOr(false),
|
|
97
|
+
hasPrev: pi->field("hasPreviousPage")->Option.flatMap(JSON.Decode.bool)->Option.getOr(false),
|
|
98
|
+
startCursor: pi->str("startCursor"),
|
|
99
|
+
endCursor: pi->str("endCursor"),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let argsOf = (entries: array<(string, JSON.t)>): dict<JSON.t> => Dict.fromArray(entries)
|
|
104
|
+
let filterOf = (entries: array<(string, JSON.t)>): JSON.t =>
|
|
105
|
+
JSON.Encode.object(Dict.fromArray(entries))
|
|
106
|
+
let orderBy = (f, dir) =>
|
|
107
|
+
JSON.Encode.object(
|
|
108
|
+
Dict.fromArray([("field", JSON.Encode.string(f)), ("direction", JSON.Encode.string(dir))]),
|
|
109
|
+
)
|
|
110
|
+
let cur = QueryDbListQuery.encodeCursor
|
|
111
|
+
|
|
112
|
+
// Assert the push-down serves this shape AND matches the spec exactly.
|
|
113
|
+
let checkPushed = async (~label, args) => {
|
|
114
|
+
let s = await build()
|
|
115
|
+
let argsDict = argsOf(args)
|
|
116
|
+
let expected =
|
|
117
|
+
QueryDbListQuery.run(
|
|
118
|
+
~items=s.fullScan(),
|
|
119
|
+
~argsDict,
|
|
120
|
+
~capability,
|
|
121
|
+
~labelField="name",
|
|
122
|
+
~decodeLocalId=_ => None,
|
|
123
|
+
)->norm
|
|
124
|
+
switch s.listPage(~argsDict, ~capability, ~labelField="name") {
|
|
125
|
+
| Some(actual) => expect(actual->norm)->toEqual(expected)
|
|
126
|
+
| None => expect("push-down for " ++ label)->toBe("returned None")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Assert the push-down declines this shape (resolver falls back to the spec).
|
|
131
|
+
let checkFallback = async args => {
|
|
132
|
+
let s = await build()
|
|
133
|
+
expect(s.listPage(~argsDict=argsOf(args), ~capability, ~labelField="name")->Option.isSome)->toBe(false)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", () => {
|
|
137
|
+
testPromise("bare page", () => checkPushed(~label="bare", []))
|
|
138
|
+
testPromise("first:2", () => checkPushed(~label="first", [("first", JSON.Encode.int(2))]))
|
|
139
|
+
testPromise("first:2 + after", () =>
|
|
140
|
+
checkPushed(~label="after", [("first", JSON.Encode.int(2)), ("after", JSON.Encode.string(cur("p-2")))])
|
|
141
|
+
)
|
|
142
|
+
testPromise("statusEq active", () =>
|
|
143
|
+
checkPushed(~label="eq", [("filter", filterOf([("statusEq", JSON.Encode.string("active"))]))])
|
|
144
|
+
)
|
|
145
|
+
testPromise("statusEq active + first:2", () =>
|
|
146
|
+
checkPushed(
|
|
147
|
+
~label="eq+first",
|
|
148
|
+
[("filter", filterOf([("statusEq", JSON.Encode.string("active"))])), ("first", JSON.Encode.int(2))],
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
testPromise("orderBy name ASC", () =>
|
|
152
|
+
checkPushed(~label="order-asc", [("orderBy", orderBy("name", "ASC"))])
|
|
153
|
+
)
|
|
154
|
+
testPromise("orderBy name DESC", () =>
|
|
155
|
+
checkPushed(~label="order-desc", [("orderBy", orderBy("name", "DESC"))])
|
|
156
|
+
)
|
|
157
|
+
testPromise("orderBy name DESC + first:2 + after", () =>
|
|
158
|
+
checkPushed(
|
|
159
|
+
~label="order-desc-after",
|
|
160
|
+
[
|
|
161
|
+
("orderBy", orderBy("name", "DESC")),
|
|
162
|
+
("first", JSON.Encode.int(2)),
|
|
163
|
+
("after", JSON.Encode.string(cur("Delta"))),
|
|
164
|
+
],
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
// status has duplicate values → exercises the id tiebreak in ORDER BY.
|
|
168
|
+
testPromise("orderBy status ASC (tiebreak by id)", () =>
|
|
169
|
+
checkPushed(~label="order-tiebreak", [("orderBy", orderBy("status", "ASC"))])
|
|
170
|
+
)
|
|
171
|
+
// qty is numeric → exercises CAST(... AS TEXT) vs Float.toString string parity.
|
|
172
|
+
testPromise("qty range From/To (numeric-as-string)", () =>
|
|
173
|
+
checkPushed(
|
|
174
|
+
~label="range",
|
|
175
|
+
[("filter", filterOf([("qtyFrom", JSON.Encode.string("2")), ("qtyTo", JSON.Encode.string("4"))]))],
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
testPromise("orderBy qty ASC (numeric-as-string sort)", () =>
|
|
179
|
+
checkPushed(~label="order-qty", [("orderBy", orderBy("qty", "ASC"))])
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
// Shapes the push-down declines → resolver uses the JS fallback.
|
|
183
|
+
testPromise("search → fallback", () =>
|
|
184
|
+
checkFallback([("filter", filterOf([("search", JSON.Encode.string("alp"))]))])
|
|
185
|
+
)
|
|
186
|
+
testPromise("searchPrefix → fallback", () =>
|
|
187
|
+
checkFallback([("filter", filterOf([("searchPrefix", JSON.Encode.string("A"))]))])
|
|
188
|
+
)
|
|
189
|
+
testPromise("ids → fallback", () =>
|
|
190
|
+
checkFallback([
|
|
191
|
+
("filter", filterOf([("ids", JSON.Encode.array([JSON.Encode.string("p-1")]))])),
|
|
192
|
+
])
|
|
193
|
+
)
|
|
194
|
+
testPromise("backward (last/before) → fallback", () =>
|
|
195
|
+
checkFallback([("last", JSON.Encode.int(2)), ("before", JSON.Encode.string(cur("p-4")))])
|
|
196
|
+
)
|
|
197
|
+
})
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
|
|
6
|
+
import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
|
|
7
|
+
import * as SqliteDriver$ReventlessLocal from "../../src/adapter/SqliteDriver.res.mjs";
|
|
8
|
+
import * as QueryDbListQuery$ReventlessLocal from "../../src/adapter/QueryDb/QueryDbListQuery.res.mjs";
|
|
9
|
+
import * as QueryDbStorage_Sqlite$ReventlessLocal from "../../src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs";
|
|
10
|
+
|
|
11
|
+
TestRunner$ReventlessLocal.setup();
|
|
12
|
+
|
|
13
|
+
let opts = {};
|
|
14
|
+
|
|
15
|
+
let capability_filterFields = [
|
|
16
|
+
{
|
|
17
|
+
name: "status",
|
|
18
|
+
gqlType: "String",
|
|
19
|
+
range: false
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "qty",
|
|
23
|
+
gqlType: "Int",
|
|
24
|
+
range: true
|
|
25
|
+
}
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
let capability_sortFields = [
|
|
29
|
+
"name",
|
|
30
|
+
"qty",
|
|
31
|
+
"status"
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
let capability = {
|
|
35
|
+
filterFields: capability_filterFields,
|
|
36
|
+
sortFields: capability_sortFields
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function mk(id, status, name, qty) {
|
|
40
|
+
let o = {};
|
|
41
|
+
o["id"] = id;
|
|
42
|
+
o["status"] = status;
|
|
43
|
+
o["name"] = name;
|
|
44
|
+
o["qty"] = qty;
|
|
45
|
+
return o;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let rows = [
|
|
49
|
+
[
|
|
50
|
+
"p-1",
|
|
51
|
+
"active",
|
|
52
|
+
"Charlie",
|
|
53
|
+
3
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
"p-2",
|
|
57
|
+
"active",
|
|
58
|
+
"Alpha",
|
|
59
|
+
1
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
"p-3",
|
|
63
|
+
"inactive",
|
|
64
|
+
"Echo",
|
|
65
|
+
5
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
"p-4",
|
|
69
|
+
"active",
|
|
70
|
+
"Bravo",
|
|
71
|
+
2
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
"p-5",
|
|
75
|
+
"inactive",
|
|
76
|
+
"Delta",
|
|
77
|
+
4
|
|
78
|
+
]
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
async function build() {
|
|
82
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
83
|
+
let db = SqliteDriver$ReventlessLocal.openDb(":memory:");
|
|
84
|
+
let DbProvider = {
|
|
85
|
+
db: db
|
|
86
|
+
};
|
|
87
|
+
let Storage = QueryDbStorage_Sqlite$ReventlessLocal.Make(TestBus)(DbProvider);
|
|
88
|
+
let s = Storage.make("items", [], undefined, undefined, undefined, undefined, opts);
|
|
89
|
+
let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
90
|
+
for (let i = 0, i_finish = rows.length; i < i_finish; ++i) {
|
|
91
|
+
let match = rows[i];
|
|
92
|
+
let id = match[0];
|
|
93
|
+
await ops.save(id, mk(id, match[1], match[2], match[3]), "Any", undefined);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
listPage: Stdlib_Option.getOrThrow(TestBus.getQueryDbListPage("items"), undefined),
|
|
97
|
+
fullScan: Stdlib_Option.getOrThrow(TestBus.getQueryDbScan("items"), undefined)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function field(j, k) {
|
|
102
|
+
return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(j), d => d[k]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function str(j, k) {
|
|
106
|
+
return Stdlib_Option.flatMap(field(j, k), Stdlib_JSON.Decode.string);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function norm(conn) {
|
|
110
|
+
let edges = Stdlib_Option.getOr(Stdlib_Option.flatMap(field(conn, "edges"), Stdlib_JSON.Decode.array), []).map(e => ({
|
|
111
|
+
id: Stdlib_Option.getOr(Stdlib_Option.flatMap(field(e, "node"), n => str(n, "id")), ""),
|
|
112
|
+
cursor: Stdlib_Option.getOr(str(e, "cursor"), "")
|
|
113
|
+
}));
|
|
114
|
+
let pi = Stdlib_Option.getOr(field(conn, "pageInfo"), null);
|
|
115
|
+
return {
|
|
116
|
+
edges: edges,
|
|
117
|
+
hasNext: Stdlib_Option.getOr(Stdlib_Option.flatMap(field(pi, "hasNextPage"), Stdlib_JSON.Decode.bool), false),
|
|
118
|
+
hasPrev: Stdlib_Option.getOr(Stdlib_Option.flatMap(field(pi, "hasPreviousPage"), Stdlib_JSON.Decode.bool), false),
|
|
119
|
+
startCursor: str(pi, "startCursor"),
|
|
120
|
+
endCursor: str(pi, "endCursor")
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function argsOf(entries) {
|
|
125
|
+
return Object.fromEntries(entries);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function filterOf(entries) {
|
|
129
|
+
return Object.fromEntries(entries);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function orderBy(f, dir) {
|
|
133
|
+
return Object.fromEntries([
|
|
134
|
+
[
|
|
135
|
+
"field",
|
|
136
|
+
f
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
"direction",
|
|
140
|
+
dir
|
|
141
|
+
]
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function checkPushed(label, args) {
|
|
146
|
+
let s = await build();
|
|
147
|
+
let argsDict = Object.fromEntries(args);
|
|
148
|
+
let expected = norm(QueryDbListQuery$ReventlessLocal.run(s.fullScan(), argsDict, capability, "name", param => {}));
|
|
149
|
+
let actual = s.listPage(argsDict, capability, "name");
|
|
150
|
+
if (actual !== undefined) {
|
|
151
|
+
globalThis.expect(norm(actual)).toEqual(expected);
|
|
152
|
+
} else {
|
|
153
|
+
globalThis.expect("push-down for " + label).toBe("returned None");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function checkFallback(args) {
|
|
158
|
+
let s = await build();
|
|
159
|
+
globalThis.expect(Stdlib_Option.isSome(s.listPage(Object.fromEntries(args), capability, "name"))).toBe(false);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", () => {
|
|
163
|
+
globalThis.test("bare page", () => checkPushed("bare", []));
|
|
164
|
+
globalThis.test("first:2", () => checkPushed("first", [[
|
|
165
|
+
"first",
|
|
166
|
+
2
|
|
167
|
+
]]));
|
|
168
|
+
globalThis.test("first:2 + after", () => checkPushed("after", [
|
|
169
|
+
[
|
|
170
|
+
"first",
|
|
171
|
+
2
|
|
172
|
+
],
|
|
173
|
+
[
|
|
174
|
+
"after",
|
|
175
|
+
QueryDbListQuery$ReventlessLocal.encodeCursor("p-2")
|
|
176
|
+
]
|
|
177
|
+
]));
|
|
178
|
+
globalThis.test("statusEq active", () => checkPushed("eq", [[
|
|
179
|
+
"filter",
|
|
180
|
+
Object.fromEntries([[
|
|
181
|
+
"statusEq",
|
|
182
|
+
"active"
|
|
183
|
+
]])
|
|
184
|
+
]]));
|
|
185
|
+
globalThis.test("statusEq active + first:2", () => checkPushed("eq+first", [
|
|
186
|
+
[
|
|
187
|
+
"filter",
|
|
188
|
+
Object.fromEntries([[
|
|
189
|
+
"statusEq",
|
|
190
|
+
"active"
|
|
191
|
+
]])
|
|
192
|
+
],
|
|
193
|
+
[
|
|
194
|
+
"first",
|
|
195
|
+
2
|
|
196
|
+
]
|
|
197
|
+
]));
|
|
198
|
+
globalThis.test("orderBy name ASC", () => checkPushed("order-asc", [[
|
|
199
|
+
"orderBy",
|
|
200
|
+
orderBy("name", "ASC")
|
|
201
|
+
]]));
|
|
202
|
+
globalThis.test("orderBy name DESC", () => checkPushed("order-desc", [[
|
|
203
|
+
"orderBy",
|
|
204
|
+
orderBy("name", "DESC")
|
|
205
|
+
]]));
|
|
206
|
+
globalThis.test("orderBy name DESC + first:2 + after", () => checkPushed("order-desc-after", [
|
|
207
|
+
[
|
|
208
|
+
"orderBy",
|
|
209
|
+
orderBy("name", "DESC")
|
|
210
|
+
],
|
|
211
|
+
[
|
|
212
|
+
"first",
|
|
213
|
+
2
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
"after",
|
|
217
|
+
QueryDbListQuery$ReventlessLocal.encodeCursor("Delta")
|
|
218
|
+
]
|
|
219
|
+
]));
|
|
220
|
+
globalThis.test("orderBy status ASC (tiebreak by id)", () => checkPushed("order-tiebreak", [[
|
|
221
|
+
"orderBy",
|
|
222
|
+
orderBy("status", "ASC")
|
|
223
|
+
]]));
|
|
224
|
+
globalThis.test("qty range From/To (numeric-as-string)", () => checkPushed("range", [[
|
|
225
|
+
"filter",
|
|
226
|
+
Object.fromEntries([
|
|
227
|
+
[
|
|
228
|
+
"qtyFrom",
|
|
229
|
+
"2"
|
|
230
|
+
],
|
|
231
|
+
[
|
|
232
|
+
"qtyTo",
|
|
233
|
+
"4"
|
|
234
|
+
]
|
|
235
|
+
])
|
|
236
|
+
]]));
|
|
237
|
+
globalThis.test("orderBy qty ASC (numeric-as-string sort)", () => checkPushed("order-qty", [[
|
|
238
|
+
"orderBy",
|
|
239
|
+
orderBy("qty", "ASC")
|
|
240
|
+
]]));
|
|
241
|
+
globalThis.test("search → fallback", () => checkFallback([[
|
|
242
|
+
"filter",
|
|
243
|
+
Object.fromEntries([[
|
|
244
|
+
"search",
|
|
245
|
+
"alp"
|
|
246
|
+
]])
|
|
247
|
+
]]));
|
|
248
|
+
globalThis.test("searchPrefix → fallback", () => checkFallback([[
|
|
249
|
+
"filter",
|
|
250
|
+
Object.fromEntries([[
|
|
251
|
+
"searchPrefix",
|
|
252
|
+
"A"
|
|
253
|
+
]])
|
|
254
|
+
]]));
|
|
255
|
+
globalThis.test("ids → fallback", () => checkFallback([[
|
|
256
|
+
"filter",
|
|
257
|
+
Object.fromEntries([[
|
|
258
|
+
"ids",
|
|
259
|
+
["p-1"]
|
|
260
|
+
]])
|
|
261
|
+
]]));
|
|
262
|
+
globalThis.test("backward (last/before) → fallback", () => checkFallback([
|
|
263
|
+
[
|
|
264
|
+
"last",
|
|
265
|
+
2
|
|
266
|
+
],
|
|
267
|
+
[
|
|
268
|
+
"before",
|
|
269
|
+
QueryDbListQuery$ReventlessLocal.encodeCursor("p-4")
|
|
270
|
+
]
|
|
271
|
+
]));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
let cur = QueryDbListQuery$ReventlessLocal.encodeCursor;
|
|
275
|
+
|
|
276
|
+
export {
|
|
277
|
+
opts,
|
|
278
|
+
capability,
|
|
279
|
+
mk,
|
|
280
|
+
rows,
|
|
281
|
+
build,
|
|
282
|
+
field,
|
|
283
|
+
str,
|
|
284
|
+
norm,
|
|
285
|
+
argsOf,
|
|
286
|
+
filterOf,
|
|
287
|
+
orderBy,
|
|
288
|
+
cur,
|
|
289
|
+
checkPushed,
|
|
290
|
+
checkFallback,
|
|
291
|
+
}
|
|
292
|
+
/* Not a pure module */
|
|
@@ -359,3 +359,79 @@ describe("QueryDb list resolver — keyset pagination", () => {
|
|
|
359
359
|
expect(pageInfoBool(response, "hasPreviousPage"))->toBe(false)
|
|
360
360
|
})
|
|
361
361
|
})
|
|
362
|
+
|
|
363
|
+
// End-to-end under SQLite: the storage registers the list push-down, so the
|
|
364
|
+
// resolver takes the pushed path (json_extract + ORDER BY … LIMIT) rather than
|
|
365
|
+
// the JS fallback. Same fixture, same expectations → proves the resolver wiring
|
|
366
|
+
// (capability derivation + args → push-down) yields identical output. Unit-level
|
|
367
|
+
// SQL≡spec parity across the full arg matrix lives in QueryDbListPushdownParityTest.
|
|
368
|
+
describe("QueryDb list resolver — SQLite push-down path", () => {
|
|
369
|
+
beforeEach(() => {
|
|
370
|
+
DomainGraphQL_Server.reset()
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
let withSqlite = async fn => {
|
|
374
|
+
let db = SqliteDriver.openDb(~path=":memory:")
|
|
375
|
+
BackendState.setSqlite(~db, ~path=":memory:")
|
|
376
|
+
let r = await fn()
|
|
377
|
+
BackendState.setMemory()
|
|
378
|
+
db->SqliteDriver.close
|
|
379
|
+
r
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
testPromise("first:2 then after walks forward without overlap", () =>
|
|
383
|
+
withSqlite(async () => {
|
|
384
|
+
let (resolver, _) = await buildFixture(~name="SqlPageA")
|
|
385
|
+
let page1 = await resolver(JSON.Encode.null, argsOf([("first", numJson(2))]), emptyCtx)
|
|
386
|
+
let edges1 = getEdges(page1)
|
|
387
|
+
expect(edges1->Array.length)->toBe(2)
|
|
388
|
+
expect(edgeNodeField(edges1->Array.getUnsafe(0), "productId"))->toBe("p-1")
|
|
389
|
+
expect(edgeNodeField(edges1->Array.getUnsafe(1), "productId"))->toBe("p-2")
|
|
390
|
+
expect(pageInfoBool(page1, "hasNextPage"))->toBe(true)
|
|
391
|
+
|
|
392
|
+
let endCursor1 = pageInfoString(page1, "endCursor")->Option.getOr("")
|
|
393
|
+
let page2 =
|
|
394
|
+
await resolver(
|
|
395
|
+
JSON.Encode.null,
|
|
396
|
+
argsOf([("first", numJson(2)), ("after", strJson(endCursor1))]),
|
|
397
|
+
emptyCtx,
|
|
398
|
+
)
|
|
399
|
+
let edges2 = getEdges(page2)
|
|
400
|
+
expect(edges2->Array.length)->toBe(2)
|
|
401
|
+
expect(edgeNodeField(edges2->Array.getUnsafe(0), "productId"))->toBe("p-3")
|
|
402
|
+
expect(edgeNodeField(edges2->Array.getUnsafe(1), "productId"))->toBe("p-4")
|
|
403
|
+
})
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
testPromise("orderBy name DESC + first:2 returns the reverse-sorted head", () =>
|
|
407
|
+
withSqlite(async () => {
|
|
408
|
+
let (resolver, _) = await buildFixture(~name="SqlPageB")
|
|
409
|
+
let orderBy = JSON.Encode.object(
|
|
410
|
+
Dict.fromArray([("field", strJson("name")), ("direction", strJson("DESC"))]),
|
|
411
|
+
)
|
|
412
|
+
let page1 =
|
|
413
|
+
await resolver(
|
|
414
|
+
JSON.Encode.null,
|
|
415
|
+
argsOf([("orderBy", orderBy), ("first", numJson(2))]),
|
|
416
|
+
emptyCtx,
|
|
417
|
+
)
|
|
418
|
+
let edges1 = getEdges(page1)
|
|
419
|
+
expect(edges1->Array.length)->toBe(2)
|
|
420
|
+
expect(edgeNodeField(edges1->Array.getUnsafe(0), "name"))->toBe("Echo")
|
|
421
|
+
expect(edgeNodeField(edges1->Array.getUnsafe(1), "name"))->toBe("Delta")
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
testPromise("statusEq active narrows the pushed page", () =>
|
|
426
|
+
withSqlite(async () => {
|
|
427
|
+
let (resolver, _) = await buildFixture(~name="SqlPageC")
|
|
428
|
+
let activeFilter = JSON.Encode.object(Dict.fromArray([("statusEq", strJson("active"))]))
|
|
429
|
+
let response =
|
|
430
|
+
await resolver(JSON.Encode.null, argsOf([("filter", activeFilter)]), emptyCtx)
|
|
431
|
+
let edges = getEdges(response)
|
|
432
|
+
expect(edges->Array.length)->toBe(3)
|
|
433
|
+
expect(edgeNodeField(edges->Array.getUnsafe(0), "productId"))->toBe("p-1")
|
|
434
|
+
expect(edgeNodeField(edges->Array.getUnsafe(2), "productId"))->toBe("p-4")
|
|
435
|
+
})
|
|
436
|
+
)
|
|
437
|
+
})
|
|
@@ -13,6 +13,8 @@ import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/co
|
|
|
13
13
|
import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
|
|
14
14
|
import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
|
|
15
15
|
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
16
|
+
import * as BackendState$ReventlessLocal from "../../src/adapter/BackendState.res.mjs";
|
|
17
|
+
import * as SqliteDriver$ReventlessLocal from "../../src/adapter/SqliteDriver.res.mjs";
|
|
16
18
|
import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
|
|
17
19
|
import * as QueryDb_Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Adapter.res.mjs";
|
|
18
20
|
import * as QueryDb_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Builder.res.mjs";
|
|
@@ -406,6 +408,88 @@ globalThis.describe("QueryDb list resolver — keyset pagination", () => {
|
|
|
406
408
|
});
|
|
407
409
|
});
|
|
408
410
|
|
|
411
|
+
globalThis.describe("QueryDb list resolver — SQLite push-down path", () => {
|
|
412
|
+
globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.reset());
|
|
413
|
+
let withSqlite = async fn => {
|
|
414
|
+
let db = SqliteDriver$ReventlessLocal.openDb(":memory:");
|
|
415
|
+
BackendState$ReventlessLocal.setSqlite(db, ":memory:");
|
|
416
|
+
let r = await fn();
|
|
417
|
+
BackendState$ReventlessLocal.setMemory();
|
|
418
|
+
SqliteDriver$ReventlessLocal.close(db);
|
|
419
|
+
return r;
|
|
420
|
+
};
|
|
421
|
+
globalThis.test("first:2 then after walks forward without overlap", () => withSqlite(async () => {
|
|
422
|
+
let match = await buildFixture("SqlPageA");
|
|
423
|
+
let resolver = match[0];
|
|
424
|
+
let page1 = await resolver(null, Object.fromEntries([[
|
|
425
|
+
"first",
|
|
426
|
+
2
|
|
427
|
+
]]), emptyCtx);
|
|
428
|
+
let edges1 = getEdges(page1);
|
|
429
|
+
globalThis.expect(edges1.length).toBe(2);
|
|
430
|
+
globalThis.expect(edgeNodeField(edges1[0], "productId")).toBe("p-1");
|
|
431
|
+
globalThis.expect(edgeNodeField(edges1[1], "productId")).toBe("p-2");
|
|
432
|
+
globalThis.expect(pageInfoBool(page1, "hasNextPage")).toBe(true);
|
|
433
|
+
let endCursor1 = Stdlib_Option.getOr(getString(pageInfo(page1), "endCursor"), "");
|
|
434
|
+
let page2 = await resolver(null, Object.fromEntries([
|
|
435
|
+
[
|
|
436
|
+
"first",
|
|
437
|
+
2
|
|
438
|
+
],
|
|
439
|
+
[
|
|
440
|
+
"after",
|
|
441
|
+
endCursor1
|
|
442
|
+
]
|
|
443
|
+
]), emptyCtx);
|
|
444
|
+
let edges2 = getEdges(page2);
|
|
445
|
+
globalThis.expect(edges2.length).toBe(2);
|
|
446
|
+
globalThis.expect(edgeNodeField(edges2[0], "productId")).toBe("p-3");
|
|
447
|
+
globalThis.expect(edgeNodeField(edges2[1], "productId")).toBe("p-4");
|
|
448
|
+
}));
|
|
449
|
+
globalThis.test("orderBy name DESC + first:2 returns the reverse-sorted head", () => withSqlite(async () => {
|
|
450
|
+
let match = await buildFixture("SqlPageB");
|
|
451
|
+
let orderBy = Object.fromEntries([
|
|
452
|
+
[
|
|
453
|
+
"field",
|
|
454
|
+
"name"
|
|
455
|
+
],
|
|
456
|
+
[
|
|
457
|
+
"direction",
|
|
458
|
+
"DESC"
|
|
459
|
+
]
|
|
460
|
+
]);
|
|
461
|
+
let page1 = await match[0](null, Object.fromEntries([
|
|
462
|
+
[
|
|
463
|
+
"orderBy",
|
|
464
|
+
orderBy
|
|
465
|
+
],
|
|
466
|
+
[
|
|
467
|
+
"first",
|
|
468
|
+
2
|
|
469
|
+
]
|
|
470
|
+
]), emptyCtx);
|
|
471
|
+
let edges1 = getEdges(page1);
|
|
472
|
+
globalThis.expect(edges1.length).toBe(2);
|
|
473
|
+
globalThis.expect(edgeNodeField(edges1[0], "name")).toBe("Echo");
|
|
474
|
+
globalThis.expect(edgeNodeField(edges1[1], "name")).toBe("Delta");
|
|
475
|
+
}));
|
|
476
|
+
globalThis.test("statusEq active narrows the pushed page", () => withSqlite(async () => {
|
|
477
|
+
let match = await buildFixture("SqlPageC");
|
|
478
|
+
let activeFilter = Object.fromEntries([[
|
|
479
|
+
"statusEq",
|
|
480
|
+
"active"
|
|
481
|
+
]]);
|
|
482
|
+
let response = await match[0](null, Object.fromEntries([[
|
|
483
|
+
"filter",
|
|
484
|
+
activeFilter
|
|
485
|
+
]]), emptyCtx);
|
|
486
|
+
let edges = getEdges(response);
|
|
487
|
+
globalThis.expect(edges.length).toBe(3);
|
|
488
|
+
globalThis.expect(edgeNodeField(edges[0], "productId")).toBe("p-1");
|
|
489
|
+
globalThis.expect(edgeNodeField(edges[2], "productId")).toBe("p-4");
|
|
490
|
+
}));
|
|
491
|
+
});
|
|
492
|
+
|
|
409
493
|
export {
|
|
410
494
|
rowStateSchema,
|
|
411
495
|
rowStateSchemaWithAnnotations,
|
|
@@ -30,6 +30,10 @@ module ItemBehavior: ReventlessCore.Behavior.T with module Spec := ItemSpec = {
|
|
|
30
30
|
|
|
31
31
|
let initialState = false
|
|
32
32
|
|
|
33
|
+
// Hand-written behavior (no @@reventless.behavior PPX) — satisfy the
|
|
34
|
+
// Behavior.T snapshot field manually.
|
|
35
|
+
let snapshot = None
|
|
36
|
+
|
|
33
37
|
let moduleUrl: string = %raw(`import.meta.url`)
|
|
34
38
|
|
|
35
39
|
let evolve = (_state, _event: ItemSpec.event) => true
|