@reventlessdev/reventless-local 3.0.0-alpha.224 → 3.0.0-alpha.226
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 +22 -0
- package/package.json +11 -11
- package/src/Platform.res +1 -1
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +1 -1
- package/src/adapter/LocalBus.res +3 -0
- package/src/adapter/LocalStateChangeDescriptor.res +46 -2
- package/src/adapter/LocalStateChangeDescriptor.res.mjs +16 -4
- package/src/adapter/QueryDb/LocalQueryDbStorage.res +1 -1
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +67 -6
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +45 -8
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +2 -0
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +2 -2
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +37 -0
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +14 -3
- package/src/reset/LocalSeedReset.res +23 -6
- package/src/reset/LocalSeedReset.res.mjs +2 -19
- package/tests/adapter/DcbEventLogStorageSqliteTest.res +1 -1
- package/tests/adapter/DcbEventLogStorageTest.res +1 -1
- package/tests/adapter/EventLogSnapshotParityTest.res +1 -1
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +7 -4
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +10 -7
- package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +2 -2
- package/tests/adapter/QueryDbListPushdownParityTest.res +82 -10
- package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +96 -26
- package/tests/adapter/QueryDbListResolverTest.res +2 -1
- package/tests/adapter/QueryDbListResolverTest.res.mjs +3 -2
- package/tests/components/readmodel/DcbReadModelE2EFixtures.res +1 -1
|
@@ -11,9 +11,20 @@
|
|
|
11
11
|
// unlink leaves it on the orphaned inode still serving every row it had, the
|
|
12
12
|
// delete looks like it worked, and the seed then fails against the untouched
|
|
13
13
|
// server with "the target store is not empty". Deleting ROWS through a second
|
|
14
|
-
// connection is
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// connection is what works instead — a query issued after the delete reads
|
|
15
|
+
// the emptied tables, so the platform need not be STOPPED for the reset.
|
|
16
|
+
//
|
|
17
|
+
// It does need RESTARTING before the re-seed, and this comment claimed
|
|
18
|
+
// otherwise for a while on the strength of "there is no in-process cache".
|
|
19
|
+
// There is: a DCB slice's decision state is held per partition in the
|
|
20
|
+
// running process, and a delete behind its back does not invalidate it. The
|
|
21
|
+
// slice goes on refusing writes for ids whose state it still remembers, so
|
|
22
|
+
// the re-seed fails with `…AlreadyExists` naming rows the store visibly does
|
|
23
|
+
// not hold — verified by the same id being refused before a restart and
|
|
24
|
+
// accepted after, against byte-identical tables.
|
|
25
|
+
//
|
|
26
|
+
// So: reset with the platform up, then restart it, then seed. Emptying the
|
|
27
|
+
// store and emptying the process are two steps, and only the first is here.
|
|
17
28
|
//
|
|
18
29
|
// • It is SCOPED. Wiping domain data leaves the plugin registry intact, so a
|
|
19
30
|
// re-seed just works — the same reason the deployed default is `domain`.
|
|
@@ -543,15 +554,21 @@ let run = (~dbPath: option<string>=?): unit => {
|
|
|
543
554
|
if total(plan) == 0 {
|
|
544
555
|
Console.log("Nothing to do.")
|
|
545
556
|
} else {
|
|
557
|
+
// Set at all ⇒ it is the answer, affirmative or not. Falling through to
|
|
558
|
+
// the prompt on an unrecognised value would ask for a TTY the runs this
|
|
559
|
+
// variable exists for do not have, so `SEED_RESET_CONFIRM=no` would
|
|
560
|
+
// throw where it plainly means "don't". Both arms read the reply
|
|
561
|
+
// through one predicate, which is what keeps the typed `y` and the
|
|
562
|
+
// env var speaking the same language.
|
|
546
563
|
let confirmed = switch Seed.Prompt.envValue("SEED_RESET_CONFIRM") {
|
|
547
|
-
| Some(
|
|
548
|
-
|
|
|
564
|
+
| Some(answer) => Seed.Prompt.isAffirmative(answer)
|
|
565
|
+
| None =>
|
|
549
566
|
let answer = await Seed.Prompt.ask(
|
|
550
567
|
`Empty ${total(
|
|
551
568
|
plan,
|
|
552
569
|
)->Int.toString} row(s)/object(s) in the "${plan.scope->scopeLabel}" scope? [y/N]: `,
|
|
553
570
|
)
|
|
554
|
-
|
|
571
|
+
Seed.Prompt.isAffirmative(answer)
|
|
555
572
|
}
|
|
556
573
|
if confirmed {
|
|
557
574
|
execute(db, plan)
|
|
@@ -499,25 +499,8 @@ function run(dbPath) {
|
|
|
499
499
|
if (total(plan) === 0) {
|
|
500
500
|
console.log("Nothing to do.");
|
|
501
501
|
} else {
|
|
502
|
-
let
|
|
503
|
-
let confirmed;
|
|
504
|
-
let exit = 0;
|
|
505
|
-
if (match$1 !== undefined) {
|
|
506
|
-
switch (match$1) {
|
|
507
|
-
case "1" :
|
|
508
|
-
case "yes" :
|
|
509
|
-
confirmed = true;
|
|
510
|
-
break;
|
|
511
|
-
default:
|
|
512
|
-
exit = 1;
|
|
513
|
-
}
|
|
514
|
-
} else {
|
|
515
|
-
exit = 1;
|
|
516
|
-
}
|
|
517
|
-
if (exit === 1) {
|
|
518
|
-
let answer = await Seed_Prompt$ReventlessSeed.ask(`Empty ` + total(plan).toString() + ` row(s)/object(s) in the "` + scopeLabel(plan.scope) + `" scope? [y/N]: `);
|
|
519
|
-
confirmed = answer.trim().toLowerCase() === "y";
|
|
520
|
-
}
|
|
502
|
+
let answer = Seed_Prompt$ReventlessSeed.envValue("SEED_RESET_CONFIRM");
|
|
503
|
+
let confirmed = answer !== undefined ? Seed_Prompt$ReventlessSeed.isAffirmative(answer) : Seed_Prompt$ReventlessSeed.isAffirmative(await Seed_Prompt$ReventlessSeed.ask(`Empty ` + total(plan).toString() + ` row(s)/object(s) in the "` + scopeLabel(plan.scope) + `" scope? [y/N]: `));
|
|
521
504
|
if (confirmed) {
|
|
522
505
|
execute(db, plan);
|
|
523
506
|
console.log(`Reset complete — the "` + scopeLabel(plan.scope) + `" scope reads empty and is re-seedable.`);
|
|
@@ -94,7 +94,7 @@ describe("DcbEventLogStorage_Sqlite", () => {
|
|
|
94
94
|
// Backend contract: an empty tag value (an absent composite-partition member) is
|
|
95
95
|
// legitimate — it appends, is recorded verbatim, and keeps matching through both
|
|
96
96
|
// the partition read and a composite read. Held identically by the in-memory and
|
|
97
|
-
// DynamoDB backends. See docs/plans/dcb-empty-tag-values-break-append.md.
|
|
97
|
+
// DynamoDB backends. See docs/plans/done/dcb-empty-tag-values-break-append.md.
|
|
98
98
|
testPromise("an empty tag value appends and stays readable", async () => {
|
|
99
99
|
await runUnderSqlite(async () => {
|
|
100
100
|
module TestBus = LocalBus.Make()
|
|
@@ -145,7 +145,7 @@ describe("DcbEventLogStorage_InMemory", () => {
|
|
|
145
145
|
// An empty tag value is a legitimate model state — an absent member of a
|
|
146
146
|
// composite partition key. Every backend must accept it, record it verbatim,
|
|
147
147
|
// and keep matching the event through its partition and composite reads. See
|
|
148
|
-
// docs/plans/dcb-empty-tag-values-break-append.md.
|
|
148
|
+
// docs/plans/done/dcb-empty-tag-values-break-append.md.
|
|
149
149
|
testPromise("an empty tag value appends and stays readable", async () => {
|
|
150
150
|
let storage = makeStorage()
|
|
151
151
|
let ops = await storage.operations->TestRunner.resolve
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Backend-parity tests for the EventLog snapshot ops
|
|
2
|
-
// (docs/plans/aggregate-snapshotting.md): the in-memory and SQLite backends
|
|
2
|
+
// (docs/plans/done/aggregate-snapshotting.md): the in-memory and SQLite backends
|
|
3
3
|
// must behave identically for latestSnapshot / writeSnapshot (keep-one) and
|
|
4
4
|
// the replayStream ~fromSeq delta read, so GWT/local tests of
|
|
5
5
|
// snapshot-enabled aggregates give the same results under either backend.
|
|
@@ -109,10 +109,11 @@ let indexedStateSchemaWithAnnotations = indexedStateSchema->S.Metadata.set(
|
|
|
109
109
|
scanSort: [],
|
|
110
110
|
semantic: [],
|
|
111
111
|
metric: [],
|
|
112
|
-
|
|
112
|
+
lifecycle: None,
|
|
113
113
|
groupBy: None,
|
|
114
114
|
visibility: None,
|
|
115
115
|
live: None,
|
|
116
|
+
retired: None,
|
|
116
117
|
},
|
|
117
118
|
)
|
|
118
119
|
|
|
@@ -139,10 +140,11 @@ let orderedStateSchemaWithAnnotations = orderedStateSchema->S.Metadata.set(
|
|
|
139
140
|
scanSort: [],
|
|
140
141
|
semantic: [],
|
|
141
142
|
metric: [],
|
|
142
|
-
|
|
143
|
+
lifecycle: None,
|
|
143
144
|
groupBy: None,
|
|
144
145
|
visibility: None,
|
|
145
146
|
live: None,
|
|
147
|
+
retired: None,
|
|
146
148
|
},
|
|
147
149
|
)
|
|
148
150
|
|
|
@@ -170,10 +172,11 @@ let scanStateSchemaWithAnnotations = scanStateSchema->S.Metadata.set(
|
|
|
170
172
|
scanSort: ["name"],
|
|
171
173
|
semantic: [],
|
|
172
174
|
metric: [],
|
|
173
|
-
|
|
175
|
+
lifecycle: None,
|
|
174
176
|
groupBy: None,
|
|
175
177
|
visibility: None,
|
|
176
178
|
live: None,
|
|
179
|
+
retired: None,
|
|
177
180
|
},
|
|
178
181
|
)
|
|
179
182
|
|
|
@@ -429,7 +432,7 @@ describe("GraphQL_SchemaInspector", () => {
|
|
|
429
432
|
// Query field should use filter + first/after/last/before args (Phase 4)
|
|
430
433
|
expect(
|
|
431
434
|
sdl->String.includes(
|
|
432
|
-
"Relay_Products(filter: RelayProductFilter, first: Int, after: String, last: Int, before: String): RelayProductConnection!",
|
|
435
|
+
"Relay_Products(filter: RelayProductFilter, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): RelayProductConnection!",
|
|
433
436
|
),
|
|
434
437
|
)->toBe(true)
|
|
435
438
|
// Connection filter input with search/searchPrefix/ids
|
|
@@ -114,10 +114,11 @@ let indexedStateSchemaWithAnnotations = S.Metadata.set(indexedStateSchema, State
|
|
|
114
114
|
scanSort: [],
|
|
115
115
|
semantic: [],
|
|
116
116
|
metric: [],
|
|
117
|
-
|
|
117
|
+
lifecycle: undefined,
|
|
118
118
|
groupBy: undefined,
|
|
119
119
|
visibility: undefined,
|
|
120
|
-
live: undefined
|
|
120
|
+
live: undefined,
|
|
121
|
+
retired: undefined
|
|
121
122
|
});
|
|
122
123
|
|
|
123
124
|
let orderedStateSchema = S.schema(s => ({
|
|
@@ -141,10 +142,11 @@ let orderedStateSchemaWithAnnotations = S.Metadata.set(orderedStateSchema, State
|
|
|
141
142
|
scanSort: [],
|
|
142
143
|
semantic: [],
|
|
143
144
|
metric: [],
|
|
144
|
-
|
|
145
|
+
lifecycle: undefined,
|
|
145
146
|
groupBy: undefined,
|
|
146
147
|
visibility: undefined,
|
|
147
|
-
live: undefined
|
|
148
|
+
live: undefined,
|
|
149
|
+
retired: undefined
|
|
148
150
|
});
|
|
149
151
|
|
|
150
152
|
let scanStateSchema = S.schema(s => ({
|
|
@@ -169,10 +171,11 @@ let scanStateSchemaWithAnnotations = S.Metadata.set(scanStateSchema, StateAnnota
|
|
|
169
171
|
scanSort: ["name"],
|
|
170
172
|
semantic: [],
|
|
171
173
|
metric: [],
|
|
172
|
-
|
|
174
|
+
lifecycle: undefined,
|
|
173
175
|
groupBy: undefined,
|
|
174
176
|
visibility: undefined,
|
|
175
|
-
live: undefined
|
|
177
|
+
live: undefined,
|
|
178
|
+
retired: undefined
|
|
176
179
|
});
|
|
177
180
|
|
|
178
181
|
globalThis.describe("GraphQL_SchemaInspector", () => {
|
|
@@ -350,7 +353,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
|
|
|
350
353
|
globalThis.expect(sdl.includes("edges: [RelayProductEdge!]!")).toBe(true);
|
|
351
354
|
globalThis.expect(sdl.includes("pageInfo: PageInfo!")).toBe(true);
|
|
352
355
|
globalThis.expect(sdl.includes("totalCount")).toBe(false);
|
|
353
|
-
globalThis.expect(sdl.includes("Relay_Products(filter: RelayProductFilter, first: Int, after: String, last: Int, before: String): RelayProductConnection!")).toBe(true);
|
|
356
|
+
globalThis.expect(sdl.includes("Relay_Products(filter: RelayProductFilter, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): RelayProductConnection!")).toBe(true);
|
|
354
357
|
globalThis.expect(sdl.includes("input RelayProductFilter")).toBe(true);
|
|
355
358
|
globalThis.expect(sdl.includes("search: String")).toBe(true);
|
|
356
359
|
globalThis.expect(sdl.includes("searchPrefix: String")).toBe(true);
|
|
@@ -69,7 +69,7 @@ globalThis.describe("LocalGraphQL_SubscriptionResolvers", () => {
|
|
|
69
69
|
"2026-05-19T12:00:00Z"
|
|
70
70
|
]
|
|
71
71
|
]);
|
|
72
|
-
let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Updated", "prod-1", state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
|
|
72
|
+
let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Updated", "prod-1", state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence(), undefined, undefined);
|
|
73
73
|
TestBus.publishStateChange("Product", descriptor);
|
|
74
74
|
let received = await consumerPromise;
|
|
75
75
|
if (received !== null) {
|
|
@@ -88,7 +88,7 @@ globalThis.describe("LocalGraphQL_SubscriptionResolvers", () => {
|
|
|
88
88
|
let foreignDescriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Updated", "cat-1", Object.fromEntries([[
|
|
89
89
|
"id",
|
|
90
90
|
"cat-1"
|
|
91
|
-
]]), LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
|
|
91
|
+
]]), LocalStateChangeDescriptor$ReventlessLocal.nextSequence(), undefined, undefined);
|
|
92
92
|
TestBus.publishStateChange("Category", foreignDescriptor);
|
|
93
93
|
let received = await consumerPromise;
|
|
94
94
|
globalThis.expect(received === null ? undefined : Primitive_option.some(received)).toEqual(undefined);
|
|
@@ -21,13 +21,21 @@ let capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability = {
|
|
|
21
21
|
sortFields: ["name", "qty", "status"],
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
let mk = (id, status, name, qty, owner) => {
|
|
24
|
+
let mk = (id, status, name, qty, owner, archived) => {
|
|
25
25
|
let o = Dict.make()
|
|
26
26
|
o->Dict.set("id", JSON.Encode.string(id))
|
|
27
27
|
o->Dict.set("status", JSON.Encode.string(status))
|
|
28
28
|
o->Dict.set("name", JSON.Encode.string(name))
|
|
29
29
|
o->Dict.set("qty", JSON.Encode.int(qty))
|
|
30
30
|
o->Dict.set("owner", JSON.Encode.string(owner))
|
|
31
|
+
// `archived` is absent on p-5, not false. A row written before the annotation
|
|
32
|
+
// existed has no such attribute, and "absent means not retired" is the rule
|
|
33
|
+
// both the spec and every push-down have to land on — get it wrong and the
|
|
34
|
+
// view empties the day someone adds `@retired`.
|
|
35
|
+
switch archived {
|
|
36
|
+
| Some(b) => o->Dict.set("archived", JSON.Encode.bool(b))
|
|
37
|
+
| None => ()
|
|
38
|
+
}
|
|
31
39
|
JSON.Encode.object(o)
|
|
32
40
|
}
|
|
33
41
|
|
|
@@ -40,11 +48,11 @@ let mk = (id, status, name, qty, owner) => {
|
|
|
40
48
|
// to work on a field the client-visible filter surface does not admit, which is
|
|
41
49
|
// the normal case and the reason the predicate travels separately.
|
|
42
50
|
let rows = [
|
|
43
|
-
("p-1", "active", "Charlie", 3, "u-a"),
|
|
44
|
-
("p-2", "active", "Alpha", 1, "u-b"),
|
|
45
|
-
("p-3", "inactive", "Echo", 5, "u-a"),
|
|
46
|
-
("p-4", "active", "Bravo", 2, "u-c"),
|
|
47
|
-
("p-5", "inactive", "Delta", 4, "u-a"),
|
|
51
|
+
("p-1", "active", "Charlie", 3, "u-a", Some(false)),
|
|
52
|
+
("p-2", "active", "Alpha", 1, "u-b", Some(true)),
|
|
53
|
+
("p-3", "inactive", "Echo", 5, "u-a", Some(false)),
|
|
54
|
+
("p-4", "active", "Bravo", 2, "u-c", Some(true)),
|
|
55
|
+
("p-5", "inactive", "Delta", 4, "u-a", None),
|
|
48
56
|
]
|
|
49
57
|
|
|
50
58
|
type setup = {
|
|
@@ -53,6 +61,7 @@ type setup = {
|
|
|
53
61
|
~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
|
|
54
62
|
~labelField: string,
|
|
55
63
|
~ownerScope: (string, string)=?,
|
|
64
|
+
~retiredScope: Reventless.OwnerScope.retiredScope=?,
|
|
56
65
|
) => option<JSON.t>,
|
|
57
66
|
fullScan: unit => array<JSON.t>,
|
|
58
67
|
}
|
|
@@ -66,8 +75,13 @@ let build = async (): setup => {
|
|
|
66
75
|
let s = Storage.make(~name="items", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
|
|
67
76
|
let ops = await s.operations->TestRunner.resolve
|
|
68
77
|
for i in 0 to rows->Array.length - 1 {
|
|
69
|
-
let (id, status, name, qty, owner) = rows->Array.getUnsafe(i)
|
|
70
|
-
let _ = await ops.save(
|
|
78
|
+
let (id, status, name, qty, owner, archived) = rows->Array.getUnsafe(i)
|
|
79
|
+
let _ = await ops.save(
|
|
80
|
+
id,
|
|
81
|
+
mk(id, status, name, qty, owner, archived),
|
|
82
|
+
ReventlessCore.QueryDb.Any,
|
|
83
|
+
None,
|
|
84
|
+
)
|
|
71
85
|
}
|
|
72
86
|
{
|
|
73
87
|
listPage: TestBus.getQueryDbListPage("items")->Option.getOrThrow,
|
|
@@ -118,7 +132,7 @@ let orderBy = (f, dir) =>
|
|
|
118
132
|
let cur = ReventlessCore.QueryDbListQuery.encodeCursor
|
|
119
133
|
|
|
120
134
|
// Assert the push-down serves this shape AND matches the spec exactly.
|
|
121
|
-
let checkPushed = async (~label, ~ownerScope=?, args) => {
|
|
135
|
+
let checkPushed = async (~label, ~ownerScope=?, ~retiredScope=?, args) => {
|
|
122
136
|
let s = await build()
|
|
123
137
|
let argsDict = argsOf(args)
|
|
124
138
|
let expected =
|
|
@@ -129,8 +143,9 @@ let checkPushed = async (~label, ~ownerScope=?, args) => {
|
|
|
129
143
|
~labelField="name",
|
|
130
144
|
~decodeLocalId=_ => None,
|
|
131
145
|
~ownerScope?,
|
|
146
|
+
~retiredScope?,
|
|
132
147
|
)->norm
|
|
133
|
-
switch s.listPage(~argsDict, ~capability, ~labelField="name", ~ownerScope?) {
|
|
148
|
+
switch s.listPage(~argsDict, ~capability, ~labelField="name", ~ownerScope?, ~retiredScope?) {
|
|
134
149
|
| Some(actual) => expect(actual->norm)->toEqual(expected)
|
|
135
150
|
| None => expect("push-down for " ++ label)->toBe("returned None")
|
|
136
151
|
}
|
|
@@ -142,6 +157,15 @@ let checkFallback = async args => {
|
|
|
142
157
|
expect(s.listPage(~argsDict=argsOf(args), ~capability, ~labelField="name")->Option.isSome)->toBe(false)
|
|
143
158
|
}
|
|
144
159
|
|
|
160
|
+
// The ids a retirement-narrowed read returns, from the push-down.
|
|
161
|
+
let liveIds = async args => {
|
|
162
|
+
let s = await build()
|
|
163
|
+
switch s.listPage(~argsDict=argsOf(args), ~capability, ~labelField="name", ~retiredScope={field: "archived", values: None}) {
|
|
164
|
+
| Some(conn) => (conn->norm).edges->Array.map(e => e.id)
|
|
165
|
+
| None => []
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
// The ids a scoped read actually returns, from the push-down.
|
|
146
170
|
let scopedIds = async (~owner, args) => {
|
|
147
171
|
let s = await build()
|
|
@@ -218,6 +242,54 @@ describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", ()
|
|
|
218
242
|
checkFallback([("last", JSON.Encode.int(2)), ("before", JSON.Encode.string(cur("p-4")))])
|
|
219
243
|
)
|
|
220
244
|
|
|
245
|
+
// ── Retirement narrowing ──────────────────────────────────────────────────
|
|
246
|
+
// Same parity argument as owner scoping below: the push-down is the path a
|
|
247
|
+
// deployment takes and the spec is the path the tests reach, so a predicate
|
|
248
|
+
// implemented in one of them is a hole every fallback-based test calls green.
|
|
249
|
+
describe("retirement narrowing", () => {
|
|
250
|
+
testPromise("bare page, narrowed", () =>
|
|
251
|
+
checkPushed(~label="retired-bare", ~retiredScope={field: "archived", values: None}, [])
|
|
252
|
+
)
|
|
253
|
+
testPromise("narrowed + orderBy", () =>
|
|
254
|
+
checkPushed(
|
|
255
|
+
~label="retired-order",
|
|
256
|
+
~retiredScope={field: "archived", values: None},
|
|
257
|
+
[("orderBy", orderBy("name", "DESC"))],
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
testPromise("narrowed + the caller's own filter", () =>
|
|
261
|
+
checkPushed(
|
|
262
|
+
~label="retired-filter",
|
|
263
|
+
~retiredScope={field: "archived", values: None},
|
|
264
|
+
[("filter", filterOf([("statusEq", JSON.Encode.string("active"))]))],
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
testPromise("narrowed + owner-scoped together", () =>
|
|
268
|
+
checkPushed(
|
|
269
|
+
~label="retired-owner",
|
|
270
|
+
~ownerScope=("owner", "u-a"),
|
|
271
|
+
~retiredScope={field: "archived", values: None},
|
|
272
|
+
[],
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// The rows, not just the parity: p-2 and p-4 are archived, p-5 carries no
|
|
277
|
+
// flag at all. Parity alone would pass if BOTH implementations were wrong
|
|
278
|
+
// the same way.
|
|
279
|
+
testPromise("drops the archived rows and keeps the one with no flag", async () => {
|
|
280
|
+
let ids = await liveIds([])
|
|
281
|
+
expect(ids)->toEqual(["p-1", "p-3", "p-5"])
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// The reason the predicate is pushed into the SQL rather than applied to the
|
|
285
|
+
// page: `first: 2` must yield two LIVE rows, not two rows of which some were
|
|
286
|
+
// filtered away afterwards.
|
|
287
|
+
testPromise("a page of first:2 is two live rows, not two rows minus the archived", async () => {
|
|
288
|
+
let ids = await liveIds([("first", JSON.Encode.int(2))])
|
|
289
|
+
expect(ids)->toEqual(["p-1", "p-3"])
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
221
293
|
// ── Owner scoping ─────────────────────────────────────────────────────────
|
|
222
294
|
// Parity matters more here than anywhere else in this file. The push-down is
|
|
223
295
|
// the path a deployment actually takes; the spec is the path the tests most
|
|
@@ -36,13 +36,16 @@ let capability = {
|
|
|
36
36
|
sortFields: capability_sortFields
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
function mk(id, status, name, qty, owner) {
|
|
39
|
+
function mk(id, status, name, qty, owner, archived) {
|
|
40
40
|
let o = {};
|
|
41
41
|
o["id"] = id;
|
|
42
42
|
o["status"] = status;
|
|
43
43
|
o["name"] = name;
|
|
44
44
|
o["qty"] = qty;
|
|
45
45
|
o["owner"] = owner;
|
|
46
|
+
if (archived !== undefined) {
|
|
47
|
+
o["archived"] = archived;
|
|
48
|
+
}
|
|
46
49
|
return o;
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -52,35 +55,40 @@ let rows = [
|
|
|
52
55
|
"active",
|
|
53
56
|
"Charlie",
|
|
54
57
|
3,
|
|
55
|
-
"u-a"
|
|
58
|
+
"u-a",
|
|
59
|
+
false
|
|
56
60
|
],
|
|
57
61
|
[
|
|
58
62
|
"p-2",
|
|
59
63
|
"active",
|
|
60
64
|
"Alpha",
|
|
61
65
|
1,
|
|
62
|
-
"u-b"
|
|
66
|
+
"u-b",
|
|
67
|
+
true
|
|
63
68
|
],
|
|
64
69
|
[
|
|
65
70
|
"p-3",
|
|
66
71
|
"inactive",
|
|
67
72
|
"Echo",
|
|
68
73
|
5,
|
|
69
|
-
"u-a"
|
|
74
|
+
"u-a",
|
|
75
|
+
false
|
|
70
76
|
],
|
|
71
77
|
[
|
|
72
78
|
"p-4",
|
|
73
79
|
"active",
|
|
74
80
|
"Bravo",
|
|
75
81
|
2,
|
|
76
|
-
"u-c"
|
|
82
|
+
"u-c",
|
|
83
|
+
true
|
|
77
84
|
],
|
|
78
85
|
[
|
|
79
86
|
"p-5",
|
|
80
87
|
"inactive",
|
|
81
88
|
"Delta",
|
|
82
89
|
4,
|
|
83
|
-
"u-a"
|
|
90
|
+
"u-a",
|
|
91
|
+
undefined
|
|
84
92
|
]
|
|
85
93
|
];
|
|
86
94
|
|
|
@@ -96,7 +104,7 @@ async function build() {
|
|
|
96
104
|
for (let i = 0, i_finish = rows.length; i < i_finish; ++i) {
|
|
97
105
|
let match = rows[i];
|
|
98
106
|
let id = match[0];
|
|
99
|
-
await ops.save(id, mk(id, match[1], match[2], match[3], match[4]), "Any", undefined);
|
|
107
|
+
await ops.save(id, mk(id, match[1], match[2], match[3], match[4], match[5]), "Any", undefined);
|
|
100
108
|
}
|
|
101
109
|
return {
|
|
102
110
|
listPage: Stdlib_Option.getOrThrow(TestBus.getQueryDbListPage("items"), undefined),
|
|
@@ -148,11 +156,11 @@ function orderBy(f, dir) {
|
|
|
148
156
|
]);
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
async function checkPushed(label, ownerScope, args) {
|
|
159
|
+
async function checkPushed(label, ownerScope, retiredScope, args) {
|
|
152
160
|
let s = await build();
|
|
153
161
|
let argsDict = Object.fromEntries(args);
|
|
154
|
-
let expected = norm(QueryDbListQuery$ReventlessCore.run(s.fullScan(), argsDict, capability, "name", param => {}, ownerScope));
|
|
155
|
-
let actual = s.listPage(argsDict, capability, "name", ownerScope);
|
|
162
|
+
let expected = norm(QueryDbListQuery$ReventlessCore.run(s.fullScan(), argsDict, capability, "name", param => {}, ownerScope, retiredScope));
|
|
163
|
+
let actual = s.listPage(argsDict, capability, "name", ownerScope, retiredScope);
|
|
156
164
|
if (actual !== undefined) {
|
|
157
165
|
globalThis.expect(norm(actual)).toEqual(expected);
|
|
158
166
|
} else {
|
|
@@ -162,7 +170,20 @@ async function checkPushed(label, ownerScope, args) {
|
|
|
162
170
|
|
|
163
171
|
async function checkFallback(args) {
|
|
164
172
|
let s = await build();
|
|
165
|
-
globalThis.expect(Stdlib_Option.isSome(s.listPage(Object.fromEntries(args), capability, "name", undefined))).toBe(false);
|
|
173
|
+
globalThis.expect(Stdlib_Option.isSome(s.listPage(Object.fromEntries(args), capability, "name", undefined, undefined))).toBe(false);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function liveIds(args) {
|
|
177
|
+
let s = await build();
|
|
178
|
+
let conn = s.listPage(Object.fromEntries(args), capability, "name", undefined, {
|
|
179
|
+
field: "archived",
|
|
180
|
+
values: undefined
|
|
181
|
+
});
|
|
182
|
+
if (conn !== undefined) {
|
|
183
|
+
return norm(conn).edges.map(e => e.id);
|
|
184
|
+
} else {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
166
187
|
}
|
|
167
188
|
|
|
168
189
|
async function scopedIds(owner, args) {
|
|
@@ -170,7 +191,7 @@ async function scopedIds(owner, args) {
|
|
|
170
191
|
let conn = s.listPage(Object.fromEntries(args), capability, "name", [
|
|
171
192
|
"owner",
|
|
172
193
|
owner
|
|
173
|
-
]);
|
|
194
|
+
], undefined);
|
|
174
195
|
if (conn !== undefined) {
|
|
175
196
|
return norm(conn).edges.map(e => e.id);
|
|
176
197
|
} else {
|
|
@@ -179,12 +200,12 @@ async function scopedIds(owner, args) {
|
|
|
179
200
|
}
|
|
180
201
|
|
|
181
202
|
globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", () => {
|
|
182
|
-
globalThis.test("bare page", () => checkPushed("bare", undefined, []));
|
|
183
|
-
globalThis.test("first:2", () => checkPushed("first", undefined, [[
|
|
203
|
+
globalThis.test("bare page", () => checkPushed("bare", undefined, undefined, []));
|
|
204
|
+
globalThis.test("first:2", () => checkPushed("first", undefined, undefined, [[
|
|
184
205
|
"first",
|
|
185
206
|
2
|
|
186
207
|
]]));
|
|
187
|
-
globalThis.test("first:2 + after", () => checkPushed("after", undefined, [
|
|
208
|
+
globalThis.test("first:2 + after", () => checkPushed("after", undefined, undefined, [
|
|
188
209
|
[
|
|
189
210
|
"first",
|
|
190
211
|
2
|
|
@@ -194,14 +215,14 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
|
|
|
194
215
|
QueryDbListQuery$ReventlessCore.encodeCursor("p-2")
|
|
195
216
|
]
|
|
196
217
|
]));
|
|
197
|
-
globalThis.test("statusEq active", () => checkPushed("eq", undefined, [[
|
|
218
|
+
globalThis.test("statusEq active", () => checkPushed("eq", undefined, undefined, [[
|
|
198
219
|
"filter",
|
|
199
220
|
Object.fromEntries([[
|
|
200
221
|
"statusEq",
|
|
201
222
|
"active"
|
|
202
223
|
]])
|
|
203
224
|
]]));
|
|
204
|
-
globalThis.test("statusEq active + first:2", () => checkPushed("eq+first", undefined, [
|
|
225
|
+
globalThis.test("statusEq active + first:2", () => checkPushed("eq+first", undefined, undefined, [
|
|
205
226
|
[
|
|
206
227
|
"filter",
|
|
207
228
|
Object.fromEntries([[
|
|
@@ -214,15 +235,15 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
|
|
|
214
235
|
2
|
|
215
236
|
]
|
|
216
237
|
]));
|
|
217
|
-
globalThis.test("orderBy name ASC", () => checkPushed("order-asc", undefined, [[
|
|
238
|
+
globalThis.test("orderBy name ASC", () => checkPushed("order-asc", undefined, undefined, [[
|
|
218
239
|
"orderBy",
|
|
219
240
|
orderBy("name", "ASC")
|
|
220
241
|
]]));
|
|
221
|
-
globalThis.test("orderBy name DESC", () => checkPushed("order-desc", undefined, [[
|
|
242
|
+
globalThis.test("orderBy name DESC", () => checkPushed("order-desc", undefined, undefined, [[
|
|
222
243
|
"orderBy",
|
|
223
244
|
orderBy("name", "DESC")
|
|
224
245
|
]]));
|
|
225
|
-
globalThis.test("orderBy name DESC + first:2 + after", () => checkPushed("order-desc-after", undefined, [
|
|
246
|
+
globalThis.test("orderBy name DESC + first:2 + after", () => checkPushed("order-desc-after", undefined, undefined, [
|
|
226
247
|
[
|
|
227
248
|
"orderBy",
|
|
228
249
|
orderBy("name", "DESC")
|
|
@@ -236,11 +257,11 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
|
|
|
236
257
|
QueryDbListQuery$ReventlessCore.encodeCursor("Delta")
|
|
237
258
|
]
|
|
238
259
|
]));
|
|
239
|
-
globalThis.test("orderBy status ASC (tiebreak by id)", () => checkPushed("order-tiebreak", undefined, [[
|
|
260
|
+
globalThis.test("orderBy status ASC (tiebreak by id)", () => checkPushed("order-tiebreak", undefined, undefined, [[
|
|
240
261
|
"orderBy",
|
|
241
262
|
orderBy("status", "ASC")
|
|
242
263
|
]]));
|
|
243
|
-
globalThis.test("qty range From/To (numeric-as-string)", () => checkPushed("range", undefined, [[
|
|
264
|
+
globalThis.test("qty range From/To (numeric-as-string)", () => checkPushed("range", undefined, undefined, [[
|
|
244
265
|
"filter",
|
|
245
266
|
Object.fromEntries([
|
|
246
267
|
[
|
|
@@ -253,7 +274,7 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
|
|
|
253
274
|
]
|
|
254
275
|
])
|
|
255
276
|
]]));
|
|
256
|
-
globalThis.test("orderBy qty ASC (numeric-as-string sort)", () => checkPushed("order-qty", undefined, [[
|
|
277
|
+
globalThis.test("orderBy qty ASC (numeric-as-string sort)", () => checkPushed("order-qty", undefined, undefined, [[
|
|
257
278
|
"orderBy",
|
|
258
279
|
orderBy("qty", "ASC")
|
|
259
280
|
]]));
|
|
@@ -288,22 +309,70 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
|
|
|
288
309
|
QueryDbListQuery$ReventlessCore.encodeCursor("p-4")
|
|
289
310
|
]
|
|
290
311
|
]));
|
|
312
|
+
globalThis.describe("retirement narrowing", () => {
|
|
313
|
+
globalThis.test("bare page, narrowed", () => checkPushed("retired-bare", undefined, {
|
|
314
|
+
field: "archived",
|
|
315
|
+
values: undefined
|
|
316
|
+
}, []));
|
|
317
|
+
globalThis.test("narrowed + orderBy", () => checkPushed("retired-order", undefined, {
|
|
318
|
+
field: "archived",
|
|
319
|
+
values: undefined
|
|
320
|
+
}, [[
|
|
321
|
+
"orderBy",
|
|
322
|
+
orderBy("name", "DESC")
|
|
323
|
+
]]));
|
|
324
|
+
globalThis.test("narrowed + the caller's own filter", () => checkPushed("retired-filter", undefined, {
|
|
325
|
+
field: "archived",
|
|
326
|
+
values: undefined
|
|
327
|
+
}, [[
|
|
328
|
+
"filter",
|
|
329
|
+
Object.fromEntries([[
|
|
330
|
+
"statusEq",
|
|
331
|
+
"active"
|
|
332
|
+
]])
|
|
333
|
+
]]));
|
|
334
|
+
globalThis.test("narrowed + owner-scoped together", () => checkPushed("retired-owner", [
|
|
335
|
+
"owner",
|
|
336
|
+
"u-a"
|
|
337
|
+
], {
|
|
338
|
+
field: "archived",
|
|
339
|
+
values: undefined
|
|
340
|
+
}, []));
|
|
341
|
+
globalThis.test("drops the archived rows and keeps the one with no flag", async () => {
|
|
342
|
+
let ids = await liveIds([]);
|
|
343
|
+
globalThis.expect(ids).toEqual([
|
|
344
|
+
"p-1",
|
|
345
|
+
"p-3",
|
|
346
|
+
"p-5"
|
|
347
|
+
]);
|
|
348
|
+
});
|
|
349
|
+
globalThis.test("a page of first:2 is two live rows, not two rows minus the archived", async () => {
|
|
350
|
+
let ids = await liveIds([[
|
|
351
|
+
"first",
|
|
352
|
+
2
|
|
353
|
+
]]);
|
|
354
|
+
globalThis.expect(ids).toEqual([
|
|
355
|
+
"p-1",
|
|
356
|
+
"p-3"
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
291
360
|
globalThis.describe("owner scoping", () => {
|
|
292
361
|
globalThis.test("bare page, scoped", () => checkPushed("owner-bare", [
|
|
293
362
|
"owner",
|
|
294
363
|
"u-a"
|
|
295
|
-
], []));
|
|
364
|
+
], undefined, []));
|
|
296
365
|
globalThis.test("scoped + orderBy", () => checkPushed("owner-order", [
|
|
297
366
|
"owner",
|
|
298
367
|
"u-a"
|
|
299
|
-
], [[
|
|
368
|
+
], undefined, [[
|
|
300
369
|
"orderBy",
|
|
301
370
|
orderBy("name", "DESC")
|
|
302
371
|
]]));
|
|
303
372
|
globalThis.test("scoped + client filter compose", () => checkPushed("owner-and-filter", [
|
|
304
373
|
"owner",
|
|
305
374
|
"u-a"
|
|
306
|
-
], [[
|
|
375
|
+
], undefined, [[
|
|
307
376
|
"filter",
|
|
308
377
|
Object.fromEntries([[
|
|
309
378
|
"statusEq",
|
|
@@ -361,6 +430,7 @@ export {
|
|
|
361
430
|
cur,
|
|
362
431
|
checkPushed,
|
|
363
432
|
checkFallback,
|
|
433
|
+
liveIds,
|
|
364
434
|
scopedIds,
|
|
365
435
|
}
|
|
366
436
|
/* Not a pure module */
|