@reventlessdev/reventless-postgres 3.0.0-alpha.5 → 3.0.0-alpha.6
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 +7 -0
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/compiler-info.json +1 -1
- package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
- package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
- package/lib/bs/src/QueryEnginePostgres.ast +0 -0
- package/lib/bs/src/QueryEnginePostgres.res +84 -0
- package/lib/bs/src/QueryEnginePostgres.res.mjs +74 -0
- package/lib/bs/tests/QueryEnginePostgresListPageTest-ReventlessPostgres.cmi +0 -0
- package/lib/bs/tests/QueryEnginePostgresListPageTest-ReventlessPostgres.cmt +0 -0
- package/lib/bs/tests/QueryEnginePostgresListPageTest.ast +0 -0
- package/lib/bs/tests/QueryEnginePostgresListPageTest.res +76 -0
- package/lib/bs/tests/QueryEnginePostgresListPageTest.res.mjs +136 -0
- package/lib/ocaml/.compiler.log +2 -2
- package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/QueryEnginePostgres.ast +0 -0
- package/lib/ocaml/QueryEnginePostgres.res +84 -0
- package/lib/ocaml/QueryEnginePostgresListPageTest-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/QueryEnginePostgresListPageTest-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/QueryEnginePostgresListPageTest.ast +0 -0
- package/lib/ocaml/QueryEnginePostgresListPageTest.res +76 -0
- package/package.json +2 -2
- package/src/QueryEnginePostgres.res +84 -0
- package/src/QueryEnginePostgres.res.mjs +74 -0
- package/tests/QueryEnginePostgresListPageTest.res +76 -0
- package/tests/QueryEnginePostgresListPageTest.res.mjs +136 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
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.6 (2026-07-06)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **reventless-postgres,reventless-aws:** items (sub-id connection) resolver for Postgres reads (B3.2c) ([2477220](https://github.com/ReventlessDev/reventless-core/commit/247722062913f5d0bcb5895b152f05033d8297d8))
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
# 3.0.0-alpha.5 (2026-07-06)
|
|
7
14
|
|
|
8
15
|
### Features
|
package/lib/bs/.compiler.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#Start(
|
|
2
|
-
#Done(
|
|
1
|
+
#Start(1783334804845)
|
|
2
|
+
#Done(1783334806753)
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
"bsc_hash": "75e3a59c95cc953608fd3dd6ea6ed68141bba4414a20a52f114261b8a48385fa",
|
|
5
5
|
"rescript_config_hash": "48ea15db3d741cf15a2ff49dd0b47d86badf2b548ecb3859a896faf1e3a82a15",
|
|
6
6
|
"runtime_path": "/home/runner/work/reventless-core/reventless-core/node_modules/@rescript/runtime",
|
|
7
|
-
"generated_at": "
|
|
7
|
+
"generated_at": "1783334806766"
|
|
8
8
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -297,6 +297,90 @@ module Make = (P: {let pool: PgDriver.pool}) => {
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
// Sub-id connection push-down: the `{single}Items(id, filter, first/after/
|
|
301
|
+
// last/before)` query. Keyset over `sub_key` WITHIN one partition — a page
|
|
302
|
+
// reads only what it returns. Reproduces QueryDbResolvers_GraphQL's items
|
|
303
|
+
// resolver (SortKey_Filter + forward/backward keyset), entirely in SQL since
|
|
304
|
+
// sub_key IS the sort key. Cursor = base64 of the sub_key value; comparisons
|
|
305
|
+
// use COLLATE "C" (byte order) to match JS string `<`. Unlike the main list
|
|
306
|
+
// there is no declined-shape fallback — every items shape maps to SQL.
|
|
307
|
+
let itemsPage = async (
|
|
308
|
+
~readModelName,
|
|
309
|
+
~subIdField as _: string,
|
|
310
|
+
~id: string,
|
|
311
|
+
~argsDict: dict<JSON.t>,
|
|
312
|
+
): JSON.t => {
|
|
313
|
+
let table = QueryDbStorage_Postgres.tableName(readModelName)
|
|
314
|
+
let filterDict =
|
|
315
|
+
argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
316
|
+
let fstr = k => filterDict->Dict.get(k)->Option.flatMap(JSON.Decode.string)
|
|
317
|
+
let orderDesc = fstr("order")->Option.mapOr(false, o => o == "DESC")
|
|
318
|
+
let last = argsDict->Dict.get("last")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
|
|
319
|
+
let first = argsDict->Dict.get("first")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
|
|
320
|
+
let after = argsDict->Dict.get("after")->Option.flatMap(JSON.Decode.string)
|
|
321
|
+
let before = argsDict->Dict.get("before")->Option.flatMap(JSON.Decode.string)
|
|
322
|
+
let isBackward = last->Option.isSome
|
|
323
|
+
|
|
324
|
+
let sk = `sub_key COLLATE "C"`
|
|
325
|
+
let b = {params: []}
|
|
326
|
+
let where = [`partition_key = ${b->param(JSON.Encode.string(id))}`, notExpired]
|
|
327
|
+
fstr("prefix")->Option.forEach(p =>
|
|
328
|
+
where->Array.push(`starts_with(sub_key, ${b->param(JSON.Encode.string(p))})`)
|
|
329
|
+
)
|
|
330
|
+
fstr("from")->Option.forEach(f => where->Array.push(`${sk} >= ${b->param(JSON.Encode.string(f))}`))
|
|
331
|
+
fstr("to")->Option.forEach(t => where->Array.push(`${sk} <= ${b->param(JSON.Encode.string(t))}`))
|
|
332
|
+
fstr("eq")->Option.forEach(e => where->Array.push(`${sk} = ${b->param(JSON.Encode.string(e))}`))
|
|
333
|
+
// Keyset: forward excludes ≤ after; backward excludes ≥ before.
|
|
334
|
+
switch (isBackward, after, before) {
|
|
335
|
+
| (false, Some(c), _) =>
|
|
336
|
+
where->Array.push(`${sk} > ${b->param(JSON.Encode.string(ReventlessCore.QueryDbListQuery.decodeCursor(c)))}`)
|
|
337
|
+
| (true, _, Some(c)) =>
|
|
338
|
+
where->Array.push(`${sk} < ${b->param(JSON.Encode.string(ReventlessCore.QueryDbListQuery.decodeCursor(c)))}`)
|
|
339
|
+
| _ => ()
|
|
340
|
+
}
|
|
341
|
+
// Page-fetch direction is the LOGICAL order forward, and its OPPOSITE
|
|
342
|
+
// backward (take the N nearest `before`, then reverse to logical) — mirrors
|
|
343
|
+
// the resolver's `reverse = isBackward ? !orderDesc : orderDesc`.
|
|
344
|
+
let fetchDesc = isBackward ? !orderDesc : orderDesc
|
|
345
|
+
let pageSize = (isBackward ? last : first)->Option.getOr(ReventlessCore.QueryDbListQuery.defaultListPageSize)
|
|
346
|
+
let sql = `SELECT partition_key, sub_key, item FROM ${table} WHERE ${where->Array.join(
|
|
347
|
+
" AND ",
|
|
348
|
+
)} ORDER BY ${sk} ${fetchDesc ? "DESC" : "ASC"} LIMIT ${b->param(JSON.Encode.int(pageSize + 1))}`
|
|
349
|
+
let rows = await P.pool->PgDriver.query(sql, b.params)
|
|
350
|
+
let hasMore = rows->Array.length > pageSize
|
|
351
|
+
let taken = rows->Array.slice(~start=0, ~end=pageSize)
|
|
352
|
+
// Backward fetched in reverse-logical order → flip back to logical.
|
|
353
|
+
let logical = isBackward ? taken->Array.toReversed : taken
|
|
354
|
+
let pageItems = logical->Array.map(decodeRow(~withId=false, ...))
|
|
355
|
+
// Cursor = the row's sub_key (== the item's subIdField value).
|
|
356
|
+
let subKeyOf = (row: dict<JSON.t>) =>
|
|
357
|
+
switch row->Dict.get("sub_key") {
|
|
358
|
+
| Some(JSON.String(s)) => s
|
|
359
|
+
| _ => ""
|
|
360
|
+
}
|
|
361
|
+
let cursors = logical->Array.map(subKeyOf)
|
|
362
|
+
let edges =
|
|
363
|
+
pageItems->Array.mapWithIndex((item, i) =>
|
|
364
|
+
Obj.magic({
|
|
365
|
+
"node": item,
|
|
366
|
+
"cursor": ReventlessCore.QueryDbListQuery.encodeCursor(cursors->Array.getUnsafe(i)),
|
|
367
|
+
})
|
|
368
|
+
)
|
|
369
|
+
let startCursor =
|
|
370
|
+
cursors->Array.get(0)->Option.map(ReventlessCore.QueryDbListQuery.encodeCursor)
|
|
371
|
+
let endCursor =
|
|
372
|
+
cursors->Array.get(cursors->Array.length - 1)->Option.map(ReventlessCore.QueryDbListQuery.encodeCursor)
|
|
373
|
+
Obj.magic({
|
|
374
|
+
"edges": edges,
|
|
375
|
+
"pageInfo": {
|
|
376
|
+
"hasNextPage": !isBackward && hasMore,
|
|
377
|
+
"hasPreviousPage": isBackward && hasMore,
|
|
378
|
+
"startCursor": startCursor->Nullable.fromOption,
|
|
379
|
+
"endCursor": endCursor->Nullable.fromOption,
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
300
384
|
let make: QueryDb_Adapter.queryEngineMaker = _allQueryDbs =>
|
|
301
385
|
Pulumi.Output.make({QueryEngine.scan, query})
|
|
302
386
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Pulumi from "@pulumi/pulumi";
|
|
6
|
+
import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
|
|
6
7
|
import * as PgDriver$ReventlessPostgres from "./PgDriver.res.mjs";
|
|
7
8
|
import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
|
|
8
9
|
import * as QueryDbStorage_Postgres$ReventlessPostgres from "./QueryDbStorage_Postgres.res.mjs";
|
|
@@ -231,6 +232,78 @@ function Make(P) {
|
|
|
231
232
|
let cursorValueOf = item => Stdlib_Option.getOr(QueryDbListQuery$ReventlessCore.getFieldString(item, cursorField), QueryDbListQuery$ReventlessCore.getId(item));
|
|
232
233
|
return QueryDbListQuery$ReventlessCore.buildConnection(pageItems, hasMore, false, cursorValueOf);
|
|
233
234
|
};
|
|
235
|
+
let itemsPage = async (readModelName, param$1, id, argsDict) => {
|
|
236
|
+
let table = QueryDbStorage_Postgres$ReventlessPostgres.tableName(readModelName);
|
|
237
|
+
let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
|
|
238
|
+
let fstr = k => Stdlib_Option.flatMap(filterDict[k], Stdlib_JSON.Decode.string);
|
|
239
|
+
let orderDesc = Stdlib_Option.mapOr(fstr("order"), false, o => o === "DESC");
|
|
240
|
+
let last = Stdlib_Option.map(Stdlib_Option.flatMap(argsDict["last"], Stdlib_JSON.Decode.float), prim => prim | 0);
|
|
241
|
+
let first = Stdlib_Option.map(Stdlib_Option.flatMap(argsDict["first"], Stdlib_JSON.Decode.float), prim => prim | 0);
|
|
242
|
+
let after = Stdlib_Option.flatMap(argsDict["after"], Stdlib_JSON.Decode.string);
|
|
243
|
+
let before = Stdlib_Option.flatMap(argsDict["before"], Stdlib_JSON.Decode.string);
|
|
244
|
+
let isBackward = Stdlib_Option.isSome(last);
|
|
245
|
+
let sk = `sub_key COLLATE "C"`;
|
|
246
|
+
let b = {
|
|
247
|
+
params: []
|
|
248
|
+
};
|
|
249
|
+
let where = [
|
|
250
|
+
`partition_key = ` + param(b, id),
|
|
251
|
+
QueryDbStorage_Postgres$ReventlessPostgres.notExpiredClause
|
|
252
|
+
];
|
|
253
|
+
Stdlib_Option.forEach(fstr("prefix"), p => {
|
|
254
|
+
where.push(`starts_with(sub_key, ` + param(b, p) + `)`);
|
|
255
|
+
});
|
|
256
|
+
Stdlib_Option.forEach(fstr("from"), f => {
|
|
257
|
+
where.push(sk + ` >= ` + param(b, f));
|
|
258
|
+
});
|
|
259
|
+
Stdlib_Option.forEach(fstr("to"), t => {
|
|
260
|
+
where.push(sk + ` <= ` + param(b, t));
|
|
261
|
+
});
|
|
262
|
+
Stdlib_Option.forEach(fstr("eq"), e => {
|
|
263
|
+
where.push(sk + ` = ` + param(b, e));
|
|
264
|
+
});
|
|
265
|
+
if (isBackward) {
|
|
266
|
+
if (before !== undefined) {
|
|
267
|
+
where.push(sk + ` < ` + param(b, QueryDbListQuery$ReventlessCore.decodeCursor(before)));
|
|
268
|
+
}
|
|
269
|
+
} else if (after !== undefined) {
|
|
270
|
+
where.push(sk + ` > ` + param(b, QueryDbListQuery$ReventlessCore.decodeCursor(after)));
|
|
271
|
+
}
|
|
272
|
+
let fetchDesc = isBackward ? !orderDesc : orderDesc;
|
|
273
|
+
let pageSize = Stdlib_Option.getOr(isBackward ? last : first, QueryDbListQuery$ReventlessCore.defaultListPageSize);
|
|
274
|
+
let sql = `SELECT partition_key, sub_key, item FROM ` + table + ` WHERE ` + where.join(" AND ") + ` ORDER BY ` + sk + ` ` + (
|
|
275
|
+
fetchDesc ? "DESC" : "ASC"
|
|
276
|
+
) + ` LIMIT ` + param(b, pageSize + 1 | 0);
|
|
277
|
+
let rows = await PgDriver$ReventlessPostgres.query(P.pool, sql, b.params);
|
|
278
|
+
let hasMore = rows.length > pageSize;
|
|
279
|
+
let taken = rows.slice(0, pageSize);
|
|
280
|
+
let logical = isBackward ? taken.toReversed() : taken;
|
|
281
|
+
let pageItems = logical.map(extra => decodeRow(false, extra));
|
|
282
|
+
let subKeyOf = row => {
|
|
283
|
+
let match = row["sub_key"];
|
|
284
|
+
if (typeof match === "string") {
|
|
285
|
+
return match;
|
|
286
|
+
} else {
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
let cursors = logical.map(subKeyOf);
|
|
291
|
+
let edges = pageItems.map((item, i) => ({
|
|
292
|
+
node: item,
|
|
293
|
+
cursor: QueryDbListQuery$ReventlessCore.encodeCursor(cursors[i])
|
|
294
|
+
}));
|
|
295
|
+
let startCursor = Stdlib_Option.map(cursors[0], QueryDbListQuery$ReventlessCore.encodeCursor);
|
|
296
|
+
let endCursor = Stdlib_Option.map(cursors[cursors.length - 1 | 0], QueryDbListQuery$ReventlessCore.encodeCursor);
|
|
297
|
+
return {
|
|
298
|
+
edges: edges,
|
|
299
|
+
pageInfo: {
|
|
300
|
+
hasNextPage: !isBackward && hasMore,
|
|
301
|
+
hasPreviousPage: isBackward && hasMore,
|
|
302
|
+
startCursor: Stdlib_Nullable.fromOption(startCursor),
|
|
303
|
+
endCursor: Stdlib_Nullable.fromOption(endCursor)
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
};
|
|
234
307
|
let make = _allQueryDbs => Pulumi.output({
|
|
235
308
|
scan: scan,
|
|
236
309
|
query: query
|
|
@@ -243,6 +316,7 @@ function Make(P) {
|
|
|
243
316
|
idExprC: idExprC,
|
|
244
317
|
jsonTextC: jsonTextC,
|
|
245
318
|
listPage: listPage,
|
|
319
|
+
itemsPage: itemsPage,
|
|
246
320
|
make: make
|
|
247
321
|
};
|
|
248
322
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -233,4 +233,80 @@ switch processEnv->Dict.get("PG_URL") {
|
|
|
233
233
|
expect(items->Array.length)->toBe(0)
|
|
234
234
|
})
|
|
235
235
|
})
|
|
236
|
+
|
|
237
|
+
// ── itemsPage (sub-id connection) ─────────────────────────────────────────
|
|
238
|
+
// A sub-id read model: partition "order-1" holds 5 lines keyed by `seq`.
|
|
239
|
+
let itemsName = "itemsparity"
|
|
240
|
+
let mkLine = seq => {
|
|
241
|
+
let o = Dict.make()
|
|
242
|
+
o->Dict.set("id", JSON.Encode.string("order-1"))
|
|
243
|
+
o->Dict.set("seq", JSON.Encode.string(seq))
|
|
244
|
+
JSON.Encode.object(o)
|
|
245
|
+
}
|
|
246
|
+
let seqs = ["a", "b", "c", "d", "e"]
|
|
247
|
+
beforeAllAsync(async () => {
|
|
248
|
+
let ops = QueryDbStorage_Postgres.makeOperations(
|
|
249
|
+
~pool,
|
|
250
|
+
~name=itemsName,
|
|
251
|
+
~indexes=[],
|
|
252
|
+
~subIdField=Some("seq"),
|
|
253
|
+
)
|
|
254
|
+
for i in 0 to seqs->Array.length - 1 {
|
|
255
|
+
let _ = await ops.save("order-1", mkLine(seqs->Array.getUnsafe(i)), QueryDb.Any, None)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// Extract node `seq` values in edge order.
|
|
260
|
+
let itemSeqs = (conn: JSON.t) =>
|
|
261
|
+
conn
|
|
262
|
+
->field("edges")
|
|
263
|
+
->Option.flatMap(JSON.Decode.array)
|
|
264
|
+
->Option.getOr([])
|
|
265
|
+
->Array.filterMap(e => e->field("node")->Option.flatMap(n => n->str("seq")))
|
|
266
|
+
|
|
267
|
+
let pageInfoBool = (conn, key) =>
|
|
268
|
+
conn->field("pageInfo")->Option.getOr(JSON.Encode.null)->field(key)->Option.flatMap(JSON.Decode.bool)->Option.getOr(false)
|
|
269
|
+
|
|
270
|
+
let itemsPage = args =>
|
|
271
|
+
Eng.itemsPage(~readModelName=itemsName, ~subIdField="seq", ~id="order-1", ~argsDict=argsOf(args))
|
|
272
|
+
|
|
273
|
+
describe("QueryEnginePostgres itemsPage (sub-id keyset)", () => {
|
|
274
|
+
testPromise("bare → all lines ASC, no next page", async () => {
|
|
275
|
+
let r = await itemsPage([])
|
|
276
|
+
expect(r->itemSeqs)->toEqual(["a", "b", "c", "d", "e"])
|
|
277
|
+
expect(pageInfoBool(r, "hasNextPage"))->toBe(false)
|
|
278
|
+
})
|
|
279
|
+
testPromise("first:2 → [a,b], hasNextPage", async () => {
|
|
280
|
+
let r = await itemsPage([("first", JSON.Encode.int(2))])
|
|
281
|
+
expect(r->itemSeqs)->toEqual(["a", "b"])
|
|
282
|
+
expect(pageInfoBool(r, "hasNextPage"))->toBe(true)
|
|
283
|
+
})
|
|
284
|
+
testPromise("first:2 after b → [c,d]", async () => {
|
|
285
|
+
let r = await itemsPage([("first", JSON.Encode.int(2)), ("after", JSON.Encode.string(cur("b")))])
|
|
286
|
+
expect(r->itemSeqs)->toEqual(["c", "d"])
|
|
287
|
+
})
|
|
288
|
+
testPromise("order DESC → [e..a]", async () => {
|
|
289
|
+
let r = await itemsPage([("filter", filterOf([("order", JSON.Encode.string("DESC"))]))])
|
|
290
|
+
expect(r->itemSeqs)->toEqual(["e", "d", "c", "b", "a"])
|
|
291
|
+
})
|
|
292
|
+
testPromise("filter eq c → [c]", async () => {
|
|
293
|
+
let r = await itemsPage([("filter", filterOf([("eq", JSON.Encode.string("c"))]))])
|
|
294
|
+
expect(r->itemSeqs)->toEqual(["c"])
|
|
295
|
+
})
|
|
296
|
+
testPromise("filter from b to d → [b,c,d]", async () => {
|
|
297
|
+
let r = await itemsPage([
|
|
298
|
+
("filter", filterOf([("from", JSON.Encode.string("b")), ("to", JSON.Encode.string("d"))])),
|
|
299
|
+
])
|
|
300
|
+
expect(r->itemSeqs)->toEqual(["b", "c", "d"])
|
|
301
|
+
})
|
|
302
|
+
testPromise("prefix c → [c]", async () => {
|
|
303
|
+
let r = await itemsPage([("filter", filterOf([("prefix", JSON.Encode.string("c"))]))])
|
|
304
|
+
expect(r->itemSeqs)->toEqual(["c"])
|
|
305
|
+
})
|
|
306
|
+
testPromise("backward last:2 before d → [b,c] (logical ASC)", async () => {
|
|
307
|
+
let r = await itemsPage([("last", JSON.Encode.int(2)), ("before", JSON.Encode.string(cur("d")))])
|
|
308
|
+
expect(r->itemSeqs)->toEqual(["b", "c"])
|
|
309
|
+
expect(pageInfoBool(r, "hasPreviousPage"))->toBe(true)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
236
312
|
}
|
|
@@ -300,6 +300,142 @@ if (url !== undefined) {
|
|
|
300
300
|
globalThis.expect(items.length).toBe(0);
|
|
301
301
|
});
|
|
302
302
|
});
|
|
303
|
+
let itemsName = "itemsparity";
|
|
304
|
+
let mkLine = seq => {
|
|
305
|
+
let o = {};
|
|
306
|
+
o["id"] = "order-1";
|
|
307
|
+
o["seq"] = seq;
|
|
308
|
+
return o;
|
|
309
|
+
};
|
|
310
|
+
let seqs = [
|
|
311
|
+
"a",
|
|
312
|
+
"b",
|
|
313
|
+
"c",
|
|
314
|
+
"d",
|
|
315
|
+
"e"
|
|
316
|
+
];
|
|
317
|
+
globalThis.beforeAll(async () => {
|
|
318
|
+
let ops = QueryDbStorage_Postgres$ReventlessPostgres.makeOperations(pool, itemsName, [], "seq");
|
|
319
|
+
for (let i = 0, i_finish = seqs.length; i < i_finish; ++i) {
|
|
320
|
+
await ops.save("order-1", mkLine(seqs[i]), "Any", undefined);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
let itemSeqs = conn => Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(conn, "edges"), Stdlib_JSON.Decode.array), []), e => Stdlib_Option.flatMap(field(e, "node"), n => str(n, "seq")));
|
|
324
|
+
let pageInfoBool = (conn, key) => Stdlib_Option.getOr(Stdlib_Option.flatMap(field(Stdlib_Option.getOr(field(conn, "pageInfo"), null), key), Stdlib_JSON.Decode.bool), false);
|
|
325
|
+
let itemsPage = args => Eng.itemsPage(itemsName, "seq", "order-1", Object.fromEntries(args));
|
|
326
|
+
globalThis.describe("QueryEnginePostgres itemsPage (sub-id keyset)", () => {
|
|
327
|
+
globalThis.test("bare → all lines ASC, no next page", async () => {
|
|
328
|
+
let r = await itemsPage([]);
|
|
329
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
330
|
+
"a",
|
|
331
|
+
"b",
|
|
332
|
+
"c",
|
|
333
|
+
"d",
|
|
334
|
+
"e"
|
|
335
|
+
]);
|
|
336
|
+
globalThis.expect(pageInfoBool(r, "hasNextPage")).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
globalThis.test("first:2 → [a,b], hasNextPage", async () => {
|
|
339
|
+
let r = await itemsPage([[
|
|
340
|
+
"first",
|
|
341
|
+
2
|
|
342
|
+
]]);
|
|
343
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
344
|
+
"a",
|
|
345
|
+
"b"
|
|
346
|
+
]);
|
|
347
|
+
globalThis.expect(pageInfoBool(r, "hasNextPage")).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
globalThis.test("first:2 after b → [c,d]", async () => {
|
|
350
|
+
let r = await itemsPage([
|
|
351
|
+
[
|
|
352
|
+
"first",
|
|
353
|
+
2
|
|
354
|
+
],
|
|
355
|
+
[
|
|
356
|
+
"after",
|
|
357
|
+
QueryDbListQuery$ReventlessCore.encodeCursor("b")
|
|
358
|
+
]
|
|
359
|
+
]);
|
|
360
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
361
|
+
"c",
|
|
362
|
+
"d"
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
globalThis.test("order DESC → [e..a]", async () => {
|
|
366
|
+
let r = await itemsPage([[
|
|
367
|
+
"filter",
|
|
368
|
+
Object.fromEntries([[
|
|
369
|
+
"order",
|
|
370
|
+
"DESC"
|
|
371
|
+
]])
|
|
372
|
+
]]);
|
|
373
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
374
|
+
"e",
|
|
375
|
+
"d",
|
|
376
|
+
"c",
|
|
377
|
+
"b",
|
|
378
|
+
"a"
|
|
379
|
+
]);
|
|
380
|
+
});
|
|
381
|
+
globalThis.test("filter eq c → [c]", async () => {
|
|
382
|
+
let r = await itemsPage([[
|
|
383
|
+
"filter",
|
|
384
|
+
Object.fromEntries([[
|
|
385
|
+
"eq",
|
|
386
|
+
"c"
|
|
387
|
+
]])
|
|
388
|
+
]]);
|
|
389
|
+
globalThis.expect(itemSeqs(r)).toEqual(["c"]);
|
|
390
|
+
});
|
|
391
|
+
globalThis.test("filter from b to d → [b,c,d]", async () => {
|
|
392
|
+
let r = await itemsPage([[
|
|
393
|
+
"filter",
|
|
394
|
+
Object.fromEntries([
|
|
395
|
+
[
|
|
396
|
+
"from",
|
|
397
|
+
"b"
|
|
398
|
+
],
|
|
399
|
+
[
|
|
400
|
+
"to",
|
|
401
|
+
"d"
|
|
402
|
+
]
|
|
403
|
+
])
|
|
404
|
+
]]);
|
|
405
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
406
|
+
"b",
|
|
407
|
+
"c",
|
|
408
|
+
"d"
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
globalThis.test("prefix c → [c]", async () => {
|
|
412
|
+
let r = await itemsPage([[
|
|
413
|
+
"filter",
|
|
414
|
+
Object.fromEntries([[
|
|
415
|
+
"prefix",
|
|
416
|
+
"c"
|
|
417
|
+
]])
|
|
418
|
+
]]);
|
|
419
|
+
globalThis.expect(itemSeqs(r)).toEqual(["c"]);
|
|
420
|
+
});
|
|
421
|
+
globalThis.test("backward last:2 before d → [b,c] (logical ASC)", async () => {
|
|
422
|
+
let r = await itemsPage([
|
|
423
|
+
[
|
|
424
|
+
"last",
|
|
425
|
+
2
|
|
426
|
+
],
|
|
427
|
+
[
|
|
428
|
+
"before",
|
|
429
|
+
QueryDbListQuery$ReventlessCore.encodeCursor("d")
|
|
430
|
+
]
|
|
431
|
+
]);
|
|
432
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
433
|
+
"b",
|
|
434
|
+
"c"
|
|
435
|
+
]);
|
|
436
|
+
globalThis.expect(pageInfoBool(r, "hasPreviousPage")).toBe(true);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
303
439
|
} else {
|
|
304
440
|
globalThis.test("Postgres list push-down parity (skipped — set PG_URL to run)", () => {
|
|
305
441
|
globalThis.expect(true).toBe(true);
|
package/lib/ocaml/.compiler.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#Start(
|
|
2
|
-
#Done(
|
|
1
|
+
#Start(1783334804845)
|
|
2
|
+
#Done(1783334806753)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -297,6 +297,90 @@ module Make = (P: {let pool: PgDriver.pool}) => {
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
// Sub-id connection push-down: the `{single}Items(id, filter, first/after/
|
|
301
|
+
// last/before)` query. Keyset over `sub_key` WITHIN one partition — a page
|
|
302
|
+
// reads only what it returns. Reproduces QueryDbResolvers_GraphQL's items
|
|
303
|
+
// resolver (SortKey_Filter + forward/backward keyset), entirely in SQL since
|
|
304
|
+
// sub_key IS the sort key. Cursor = base64 of the sub_key value; comparisons
|
|
305
|
+
// use COLLATE "C" (byte order) to match JS string `<`. Unlike the main list
|
|
306
|
+
// there is no declined-shape fallback — every items shape maps to SQL.
|
|
307
|
+
let itemsPage = async (
|
|
308
|
+
~readModelName,
|
|
309
|
+
~subIdField as _: string,
|
|
310
|
+
~id: string,
|
|
311
|
+
~argsDict: dict<JSON.t>,
|
|
312
|
+
): JSON.t => {
|
|
313
|
+
let table = QueryDbStorage_Postgres.tableName(readModelName)
|
|
314
|
+
let filterDict =
|
|
315
|
+
argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
316
|
+
let fstr = k => filterDict->Dict.get(k)->Option.flatMap(JSON.Decode.string)
|
|
317
|
+
let orderDesc = fstr("order")->Option.mapOr(false, o => o == "DESC")
|
|
318
|
+
let last = argsDict->Dict.get("last")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
|
|
319
|
+
let first = argsDict->Dict.get("first")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
|
|
320
|
+
let after = argsDict->Dict.get("after")->Option.flatMap(JSON.Decode.string)
|
|
321
|
+
let before = argsDict->Dict.get("before")->Option.flatMap(JSON.Decode.string)
|
|
322
|
+
let isBackward = last->Option.isSome
|
|
323
|
+
|
|
324
|
+
let sk = `sub_key COLLATE "C"`
|
|
325
|
+
let b = {params: []}
|
|
326
|
+
let where = [`partition_key = ${b->param(JSON.Encode.string(id))}`, notExpired]
|
|
327
|
+
fstr("prefix")->Option.forEach(p =>
|
|
328
|
+
where->Array.push(`starts_with(sub_key, ${b->param(JSON.Encode.string(p))})`)
|
|
329
|
+
)
|
|
330
|
+
fstr("from")->Option.forEach(f => where->Array.push(`${sk} >= ${b->param(JSON.Encode.string(f))}`))
|
|
331
|
+
fstr("to")->Option.forEach(t => where->Array.push(`${sk} <= ${b->param(JSON.Encode.string(t))}`))
|
|
332
|
+
fstr("eq")->Option.forEach(e => where->Array.push(`${sk} = ${b->param(JSON.Encode.string(e))}`))
|
|
333
|
+
// Keyset: forward excludes ≤ after; backward excludes ≥ before.
|
|
334
|
+
switch (isBackward, after, before) {
|
|
335
|
+
| (false, Some(c), _) =>
|
|
336
|
+
where->Array.push(`${sk} > ${b->param(JSON.Encode.string(ReventlessCore.QueryDbListQuery.decodeCursor(c)))}`)
|
|
337
|
+
| (true, _, Some(c)) =>
|
|
338
|
+
where->Array.push(`${sk} < ${b->param(JSON.Encode.string(ReventlessCore.QueryDbListQuery.decodeCursor(c)))}`)
|
|
339
|
+
| _ => ()
|
|
340
|
+
}
|
|
341
|
+
// Page-fetch direction is the LOGICAL order forward, and its OPPOSITE
|
|
342
|
+
// backward (take the N nearest `before`, then reverse to logical) — mirrors
|
|
343
|
+
// the resolver's `reverse = isBackward ? !orderDesc : orderDesc`.
|
|
344
|
+
let fetchDesc = isBackward ? !orderDesc : orderDesc
|
|
345
|
+
let pageSize = (isBackward ? last : first)->Option.getOr(ReventlessCore.QueryDbListQuery.defaultListPageSize)
|
|
346
|
+
let sql = `SELECT partition_key, sub_key, item FROM ${table} WHERE ${where->Array.join(
|
|
347
|
+
" AND ",
|
|
348
|
+
)} ORDER BY ${sk} ${fetchDesc ? "DESC" : "ASC"} LIMIT ${b->param(JSON.Encode.int(pageSize + 1))}`
|
|
349
|
+
let rows = await P.pool->PgDriver.query(sql, b.params)
|
|
350
|
+
let hasMore = rows->Array.length > pageSize
|
|
351
|
+
let taken = rows->Array.slice(~start=0, ~end=pageSize)
|
|
352
|
+
// Backward fetched in reverse-logical order → flip back to logical.
|
|
353
|
+
let logical = isBackward ? taken->Array.toReversed : taken
|
|
354
|
+
let pageItems = logical->Array.map(decodeRow(~withId=false, ...))
|
|
355
|
+
// Cursor = the row's sub_key (== the item's subIdField value).
|
|
356
|
+
let subKeyOf = (row: dict<JSON.t>) =>
|
|
357
|
+
switch row->Dict.get("sub_key") {
|
|
358
|
+
| Some(JSON.String(s)) => s
|
|
359
|
+
| _ => ""
|
|
360
|
+
}
|
|
361
|
+
let cursors = logical->Array.map(subKeyOf)
|
|
362
|
+
let edges =
|
|
363
|
+
pageItems->Array.mapWithIndex((item, i) =>
|
|
364
|
+
Obj.magic({
|
|
365
|
+
"node": item,
|
|
366
|
+
"cursor": ReventlessCore.QueryDbListQuery.encodeCursor(cursors->Array.getUnsafe(i)),
|
|
367
|
+
})
|
|
368
|
+
)
|
|
369
|
+
let startCursor =
|
|
370
|
+
cursors->Array.get(0)->Option.map(ReventlessCore.QueryDbListQuery.encodeCursor)
|
|
371
|
+
let endCursor =
|
|
372
|
+
cursors->Array.get(cursors->Array.length - 1)->Option.map(ReventlessCore.QueryDbListQuery.encodeCursor)
|
|
373
|
+
Obj.magic({
|
|
374
|
+
"edges": edges,
|
|
375
|
+
"pageInfo": {
|
|
376
|
+
"hasNextPage": !isBackward && hasMore,
|
|
377
|
+
"hasPreviousPage": isBackward && hasMore,
|
|
378
|
+
"startCursor": startCursor->Nullable.fromOption,
|
|
379
|
+
"endCursor": endCursor->Nullable.fromOption,
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
300
384
|
let make: QueryDb_Adapter.queryEngineMaker = _allQueryDbs =>
|
|
301
385
|
Pulumi.Output.make({QueryEngine.scan, query})
|
|
302
386
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -233,4 +233,80 @@ switch processEnv->Dict.get("PG_URL") {
|
|
|
233
233
|
expect(items->Array.length)->toBe(0)
|
|
234
234
|
})
|
|
235
235
|
})
|
|
236
|
+
|
|
237
|
+
// ── itemsPage (sub-id connection) ─────────────────────────────────────────
|
|
238
|
+
// A sub-id read model: partition "order-1" holds 5 lines keyed by `seq`.
|
|
239
|
+
let itemsName = "itemsparity"
|
|
240
|
+
let mkLine = seq => {
|
|
241
|
+
let o = Dict.make()
|
|
242
|
+
o->Dict.set("id", JSON.Encode.string("order-1"))
|
|
243
|
+
o->Dict.set("seq", JSON.Encode.string(seq))
|
|
244
|
+
JSON.Encode.object(o)
|
|
245
|
+
}
|
|
246
|
+
let seqs = ["a", "b", "c", "d", "e"]
|
|
247
|
+
beforeAllAsync(async () => {
|
|
248
|
+
let ops = QueryDbStorage_Postgres.makeOperations(
|
|
249
|
+
~pool,
|
|
250
|
+
~name=itemsName,
|
|
251
|
+
~indexes=[],
|
|
252
|
+
~subIdField=Some("seq"),
|
|
253
|
+
)
|
|
254
|
+
for i in 0 to seqs->Array.length - 1 {
|
|
255
|
+
let _ = await ops.save("order-1", mkLine(seqs->Array.getUnsafe(i)), QueryDb.Any, None)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// Extract node `seq` values in edge order.
|
|
260
|
+
let itemSeqs = (conn: JSON.t) =>
|
|
261
|
+
conn
|
|
262
|
+
->field("edges")
|
|
263
|
+
->Option.flatMap(JSON.Decode.array)
|
|
264
|
+
->Option.getOr([])
|
|
265
|
+
->Array.filterMap(e => e->field("node")->Option.flatMap(n => n->str("seq")))
|
|
266
|
+
|
|
267
|
+
let pageInfoBool = (conn, key) =>
|
|
268
|
+
conn->field("pageInfo")->Option.getOr(JSON.Encode.null)->field(key)->Option.flatMap(JSON.Decode.bool)->Option.getOr(false)
|
|
269
|
+
|
|
270
|
+
let itemsPage = args =>
|
|
271
|
+
Eng.itemsPage(~readModelName=itemsName, ~subIdField="seq", ~id="order-1", ~argsDict=argsOf(args))
|
|
272
|
+
|
|
273
|
+
describe("QueryEnginePostgres itemsPage (sub-id keyset)", () => {
|
|
274
|
+
testPromise("bare → all lines ASC, no next page", async () => {
|
|
275
|
+
let r = await itemsPage([])
|
|
276
|
+
expect(r->itemSeqs)->toEqual(["a", "b", "c", "d", "e"])
|
|
277
|
+
expect(pageInfoBool(r, "hasNextPage"))->toBe(false)
|
|
278
|
+
})
|
|
279
|
+
testPromise("first:2 → [a,b], hasNextPage", async () => {
|
|
280
|
+
let r = await itemsPage([("first", JSON.Encode.int(2))])
|
|
281
|
+
expect(r->itemSeqs)->toEqual(["a", "b"])
|
|
282
|
+
expect(pageInfoBool(r, "hasNextPage"))->toBe(true)
|
|
283
|
+
})
|
|
284
|
+
testPromise("first:2 after b → [c,d]", async () => {
|
|
285
|
+
let r = await itemsPage([("first", JSON.Encode.int(2)), ("after", JSON.Encode.string(cur("b")))])
|
|
286
|
+
expect(r->itemSeqs)->toEqual(["c", "d"])
|
|
287
|
+
})
|
|
288
|
+
testPromise("order DESC → [e..a]", async () => {
|
|
289
|
+
let r = await itemsPage([("filter", filterOf([("order", JSON.Encode.string("DESC"))]))])
|
|
290
|
+
expect(r->itemSeqs)->toEqual(["e", "d", "c", "b", "a"])
|
|
291
|
+
})
|
|
292
|
+
testPromise("filter eq c → [c]", async () => {
|
|
293
|
+
let r = await itemsPage([("filter", filterOf([("eq", JSON.Encode.string("c"))]))])
|
|
294
|
+
expect(r->itemSeqs)->toEqual(["c"])
|
|
295
|
+
})
|
|
296
|
+
testPromise("filter from b to d → [b,c,d]", async () => {
|
|
297
|
+
let r = await itemsPage([
|
|
298
|
+
("filter", filterOf([("from", JSON.Encode.string("b")), ("to", JSON.Encode.string("d"))])),
|
|
299
|
+
])
|
|
300
|
+
expect(r->itemSeqs)->toEqual(["b", "c", "d"])
|
|
301
|
+
})
|
|
302
|
+
testPromise("prefix c → [c]", async () => {
|
|
303
|
+
let r = await itemsPage([("filter", filterOf([("prefix", JSON.Encode.string("c"))]))])
|
|
304
|
+
expect(r->itemSeqs)->toEqual(["c"])
|
|
305
|
+
})
|
|
306
|
+
testPromise("backward last:2 before d → [b,c] (logical ASC)", async () => {
|
|
307
|
+
let r = await itemsPage([("last", JSON.Encode.int(2)), ("before", JSON.Encode.string(cur("d")))])
|
|
308
|
+
expect(r->itemSeqs)->toEqual(["b", "c"])
|
|
309
|
+
expect(pageInfoBool(r, "hasPreviousPage"))->toBe(true)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
236
312
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-postgres",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.6",
|
|
4
4
|
"description": "Postgres storage backend for Reventless (classic + DCB event logs, query DB, change feed). Connection-string only — RDS/Aurora, any managed provider, or a local container.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"pg": "^8.13.1",
|
|
24
24
|
"sury": "11.0.0-alpha.4",
|
|
25
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.6",
|
|
26
25
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.25",
|
|
27
26
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
|
|
28
27
|
"@reventlessdev/reventless-core": "3.0.0-alpha.143",
|
|
29
28
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.89",
|
|
29
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.6",
|
|
30
30
|
"@reventlessdev/reventless-spec": "3.0.0-alpha.67"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
@@ -297,6 +297,90 @@ module Make = (P: {let pool: PgDriver.pool}) => {
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
// Sub-id connection push-down: the `{single}Items(id, filter, first/after/
|
|
301
|
+
// last/before)` query. Keyset over `sub_key` WITHIN one partition — a page
|
|
302
|
+
// reads only what it returns. Reproduces QueryDbResolvers_GraphQL's items
|
|
303
|
+
// resolver (SortKey_Filter + forward/backward keyset), entirely in SQL since
|
|
304
|
+
// sub_key IS the sort key. Cursor = base64 of the sub_key value; comparisons
|
|
305
|
+
// use COLLATE "C" (byte order) to match JS string `<`. Unlike the main list
|
|
306
|
+
// there is no declined-shape fallback — every items shape maps to SQL.
|
|
307
|
+
let itemsPage = async (
|
|
308
|
+
~readModelName,
|
|
309
|
+
~subIdField as _: string,
|
|
310
|
+
~id: string,
|
|
311
|
+
~argsDict: dict<JSON.t>,
|
|
312
|
+
): JSON.t => {
|
|
313
|
+
let table = QueryDbStorage_Postgres.tableName(readModelName)
|
|
314
|
+
let filterDict =
|
|
315
|
+
argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
316
|
+
let fstr = k => filterDict->Dict.get(k)->Option.flatMap(JSON.Decode.string)
|
|
317
|
+
let orderDesc = fstr("order")->Option.mapOr(false, o => o == "DESC")
|
|
318
|
+
let last = argsDict->Dict.get("last")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
|
|
319
|
+
let first = argsDict->Dict.get("first")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
|
|
320
|
+
let after = argsDict->Dict.get("after")->Option.flatMap(JSON.Decode.string)
|
|
321
|
+
let before = argsDict->Dict.get("before")->Option.flatMap(JSON.Decode.string)
|
|
322
|
+
let isBackward = last->Option.isSome
|
|
323
|
+
|
|
324
|
+
let sk = `sub_key COLLATE "C"`
|
|
325
|
+
let b = {params: []}
|
|
326
|
+
let where = [`partition_key = ${b->param(JSON.Encode.string(id))}`, notExpired]
|
|
327
|
+
fstr("prefix")->Option.forEach(p =>
|
|
328
|
+
where->Array.push(`starts_with(sub_key, ${b->param(JSON.Encode.string(p))})`)
|
|
329
|
+
)
|
|
330
|
+
fstr("from")->Option.forEach(f => where->Array.push(`${sk} >= ${b->param(JSON.Encode.string(f))}`))
|
|
331
|
+
fstr("to")->Option.forEach(t => where->Array.push(`${sk} <= ${b->param(JSON.Encode.string(t))}`))
|
|
332
|
+
fstr("eq")->Option.forEach(e => where->Array.push(`${sk} = ${b->param(JSON.Encode.string(e))}`))
|
|
333
|
+
// Keyset: forward excludes ≤ after; backward excludes ≥ before.
|
|
334
|
+
switch (isBackward, after, before) {
|
|
335
|
+
| (false, Some(c), _) =>
|
|
336
|
+
where->Array.push(`${sk} > ${b->param(JSON.Encode.string(ReventlessCore.QueryDbListQuery.decodeCursor(c)))}`)
|
|
337
|
+
| (true, _, Some(c)) =>
|
|
338
|
+
where->Array.push(`${sk} < ${b->param(JSON.Encode.string(ReventlessCore.QueryDbListQuery.decodeCursor(c)))}`)
|
|
339
|
+
| _ => ()
|
|
340
|
+
}
|
|
341
|
+
// Page-fetch direction is the LOGICAL order forward, and its OPPOSITE
|
|
342
|
+
// backward (take the N nearest `before`, then reverse to logical) — mirrors
|
|
343
|
+
// the resolver's `reverse = isBackward ? !orderDesc : orderDesc`.
|
|
344
|
+
let fetchDesc = isBackward ? !orderDesc : orderDesc
|
|
345
|
+
let pageSize = (isBackward ? last : first)->Option.getOr(ReventlessCore.QueryDbListQuery.defaultListPageSize)
|
|
346
|
+
let sql = `SELECT partition_key, sub_key, item FROM ${table} WHERE ${where->Array.join(
|
|
347
|
+
" AND ",
|
|
348
|
+
)} ORDER BY ${sk} ${fetchDesc ? "DESC" : "ASC"} LIMIT ${b->param(JSON.Encode.int(pageSize + 1))}`
|
|
349
|
+
let rows = await P.pool->PgDriver.query(sql, b.params)
|
|
350
|
+
let hasMore = rows->Array.length > pageSize
|
|
351
|
+
let taken = rows->Array.slice(~start=0, ~end=pageSize)
|
|
352
|
+
// Backward fetched in reverse-logical order → flip back to logical.
|
|
353
|
+
let logical = isBackward ? taken->Array.toReversed : taken
|
|
354
|
+
let pageItems = logical->Array.map(decodeRow(~withId=false, ...))
|
|
355
|
+
// Cursor = the row's sub_key (== the item's subIdField value).
|
|
356
|
+
let subKeyOf = (row: dict<JSON.t>) =>
|
|
357
|
+
switch row->Dict.get("sub_key") {
|
|
358
|
+
| Some(JSON.String(s)) => s
|
|
359
|
+
| _ => ""
|
|
360
|
+
}
|
|
361
|
+
let cursors = logical->Array.map(subKeyOf)
|
|
362
|
+
let edges =
|
|
363
|
+
pageItems->Array.mapWithIndex((item, i) =>
|
|
364
|
+
Obj.magic({
|
|
365
|
+
"node": item,
|
|
366
|
+
"cursor": ReventlessCore.QueryDbListQuery.encodeCursor(cursors->Array.getUnsafe(i)),
|
|
367
|
+
})
|
|
368
|
+
)
|
|
369
|
+
let startCursor =
|
|
370
|
+
cursors->Array.get(0)->Option.map(ReventlessCore.QueryDbListQuery.encodeCursor)
|
|
371
|
+
let endCursor =
|
|
372
|
+
cursors->Array.get(cursors->Array.length - 1)->Option.map(ReventlessCore.QueryDbListQuery.encodeCursor)
|
|
373
|
+
Obj.magic({
|
|
374
|
+
"edges": edges,
|
|
375
|
+
"pageInfo": {
|
|
376
|
+
"hasNextPage": !isBackward && hasMore,
|
|
377
|
+
"hasPreviousPage": isBackward && hasMore,
|
|
378
|
+
"startCursor": startCursor->Nullable.fromOption,
|
|
379
|
+
"endCursor": endCursor->Nullable.fromOption,
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
300
384
|
let make: QueryDb_Adapter.queryEngineMaker = _allQueryDbs =>
|
|
301
385
|
Pulumi.Output.make({QueryEngine.scan, query})
|
|
302
386
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Pulumi from "@pulumi/pulumi";
|
|
6
|
+
import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
|
|
6
7
|
import * as PgDriver$ReventlessPostgres from "./PgDriver.res.mjs";
|
|
7
8
|
import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
|
|
8
9
|
import * as QueryDbStorage_Postgres$ReventlessPostgres from "./QueryDbStorage_Postgres.res.mjs";
|
|
@@ -231,6 +232,78 @@ function Make(P) {
|
|
|
231
232
|
let cursorValueOf = item => Stdlib_Option.getOr(QueryDbListQuery$ReventlessCore.getFieldString(item, cursorField), QueryDbListQuery$ReventlessCore.getId(item));
|
|
232
233
|
return QueryDbListQuery$ReventlessCore.buildConnection(pageItems, hasMore, false, cursorValueOf);
|
|
233
234
|
};
|
|
235
|
+
let itemsPage = async (readModelName, param$1, id, argsDict) => {
|
|
236
|
+
let table = QueryDbStorage_Postgres$ReventlessPostgres.tableName(readModelName);
|
|
237
|
+
let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
|
|
238
|
+
let fstr = k => Stdlib_Option.flatMap(filterDict[k], Stdlib_JSON.Decode.string);
|
|
239
|
+
let orderDesc = Stdlib_Option.mapOr(fstr("order"), false, o => o === "DESC");
|
|
240
|
+
let last = Stdlib_Option.map(Stdlib_Option.flatMap(argsDict["last"], Stdlib_JSON.Decode.float), prim => prim | 0);
|
|
241
|
+
let first = Stdlib_Option.map(Stdlib_Option.flatMap(argsDict["first"], Stdlib_JSON.Decode.float), prim => prim | 0);
|
|
242
|
+
let after = Stdlib_Option.flatMap(argsDict["after"], Stdlib_JSON.Decode.string);
|
|
243
|
+
let before = Stdlib_Option.flatMap(argsDict["before"], Stdlib_JSON.Decode.string);
|
|
244
|
+
let isBackward = Stdlib_Option.isSome(last);
|
|
245
|
+
let sk = `sub_key COLLATE "C"`;
|
|
246
|
+
let b = {
|
|
247
|
+
params: []
|
|
248
|
+
};
|
|
249
|
+
let where = [
|
|
250
|
+
`partition_key = ` + param(b, id),
|
|
251
|
+
QueryDbStorage_Postgres$ReventlessPostgres.notExpiredClause
|
|
252
|
+
];
|
|
253
|
+
Stdlib_Option.forEach(fstr("prefix"), p => {
|
|
254
|
+
where.push(`starts_with(sub_key, ` + param(b, p) + `)`);
|
|
255
|
+
});
|
|
256
|
+
Stdlib_Option.forEach(fstr("from"), f => {
|
|
257
|
+
where.push(sk + ` >= ` + param(b, f));
|
|
258
|
+
});
|
|
259
|
+
Stdlib_Option.forEach(fstr("to"), t => {
|
|
260
|
+
where.push(sk + ` <= ` + param(b, t));
|
|
261
|
+
});
|
|
262
|
+
Stdlib_Option.forEach(fstr("eq"), e => {
|
|
263
|
+
where.push(sk + ` = ` + param(b, e));
|
|
264
|
+
});
|
|
265
|
+
if (isBackward) {
|
|
266
|
+
if (before !== undefined) {
|
|
267
|
+
where.push(sk + ` < ` + param(b, QueryDbListQuery$ReventlessCore.decodeCursor(before)));
|
|
268
|
+
}
|
|
269
|
+
} else if (after !== undefined) {
|
|
270
|
+
where.push(sk + ` > ` + param(b, QueryDbListQuery$ReventlessCore.decodeCursor(after)));
|
|
271
|
+
}
|
|
272
|
+
let fetchDesc = isBackward ? !orderDesc : orderDesc;
|
|
273
|
+
let pageSize = Stdlib_Option.getOr(isBackward ? last : first, QueryDbListQuery$ReventlessCore.defaultListPageSize);
|
|
274
|
+
let sql = `SELECT partition_key, sub_key, item FROM ` + table + ` WHERE ` + where.join(" AND ") + ` ORDER BY ` + sk + ` ` + (
|
|
275
|
+
fetchDesc ? "DESC" : "ASC"
|
|
276
|
+
) + ` LIMIT ` + param(b, pageSize + 1 | 0);
|
|
277
|
+
let rows = await PgDriver$ReventlessPostgres.query(P.pool, sql, b.params);
|
|
278
|
+
let hasMore = rows.length > pageSize;
|
|
279
|
+
let taken = rows.slice(0, pageSize);
|
|
280
|
+
let logical = isBackward ? taken.toReversed() : taken;
|
|
281
|
+
let pageItems = logical.map(extra => decodeRow(false, extra));
|
|
282
|
+
let subKeyOf = row => {
|
|
283
|
+
let match = row["sub_key"];
|
|
284
|
+
if (typeof match === "string") {
|
|
285
|
+
return match;
|
|
286
|
+
} else {
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
let cursors = logical.map(subKeyOf);
|
|
291
|
+
let edges = pageItems.map((item, i) => ({
|
|
292
|
+
node: item,
|
|
293
|
+
cursor: QueryDbListQuery$ReventlessCore.encodeCursor(cursors[i])
|
|
294
|
+
}));
|
|
295
|
+
let startCursor = Stdlib_Option.map(cursors[0], QueryDbListQuery$ReventlessCore.encodeCursor);
|
|
296
|
+
let endCursor = Stdlib_Option.map(cursors[cursors.length - 1 | 0], QueryDbListQuery$ReventlessCore.encodeCursor);
|
|
297
|
+
return {
|
|
298
|
+
edges: edges,
|
|
299
|
+
pageInfo: {
|
|
300
|
+
hasNextPage: !isBackward && hasMore,
|
|
301
|
+
hasPreviousPage: isBackward && hasMore,
|
|
302
|
+
startCursor: Stdlib_Nullable.fromOption(startCursor),
|
|
303
|
+
endCursor: Stdlib_Nullable.fromOption(endCursor)
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
};
|
|
234
307
|
let make = _allQueryDbs => Pulumi.output({
|
|
235
308
|
scan: scan,
|
|
236
309
|
query: query
|
|
@@ -243,6 +316,7 @@ function Make(P) {
|
|
|
243
316
|
idExprC: idExprC,
|
|
244
317
|
jsonTextC: jsonTextC,
|
|
245
318
|
listPage: listPage,
|
|
319
|
+
itemsPage: itemsPage,
|
|
246
320
|
make: make
|
|
247
321
|
};
|
|
248
322
|
}
|
|
@@ -233,4 +233,80 @@ switch processEnv->Dict.get("PG_URL") {
|
|
|
233
233
|
expect(items->Array.length)->toBe(0)
|
|
234
234
|
})
|
|
235
235
|
})
|
|
236
|
+
|
|
237
|
+
// ── itemsPage (sub-id connection) ─────────────────────────────────────────
|
|
238
|
+
// A sub-id read model: partition "order-1" holds 5 lines keyed by `seq`.
|
|
239
|
+
let itemsName = "itemsparity"
|
|
240
|
+
let mkLine = seq => {
|
|
241
|
+
let o = Dict.make()
|
|
242
|
+
o->Dict.set("id", JSON.Encode.string("order-1"))
|
|
243
|
+
o->Dict.set("seq", JSON.Encode.string(seq))
|
|
244
|
+
JSON.Encode.object(o)
|
|
245
|
+
}
|
|
246
|
+
let seqs = ["a", "b", "c", "d", "e"]
|
|
247
|
+
beforeAllAsync(async () => {
|
|
248
|
+
let ops = QueryDbStorage_Postgres.makeOperations(
|
|
249
|
+
~pool,
|
|
250
|
+
~name=itemsName,
|
|
251
|
+
~indexes=[],
|
|
252
|
+
~subIdField=Some("seq"),
|
|
253
|
+
)
|
|
254
|
+
for i in 0 to seqs->Array.length - 1 {
|
|
255
|
+
let _ = await ops.save("order-1", mkLine(seqs->Array.getUnsafe(i)), QueryDb.Any, None)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// Extract node `seq` values in edge order.
|
|
260
|
+
let itemSeqs = (conn: JSON.t) =>
|
|
261
|
+
conn
|
|
262
|
+
->field("edges")
|
|
263
|
+
->Option.flatMap(JSON.Decode.array)
|
|
264
|
+
->Option.getOr([])
|
|
265
|
+
->Array.filterMap(e => e->field("node")->Option.flatMap(n => n->str("seq")))
|
|
266
|
+
|
|
267
|
+
let pageInfoBool = (conn, key) =>
|
|
268
|
+
conn->field("pageInfo")->Option.getOr(JSON.Encode.null)->field(key)->Option.flatMap(JSON.Decode.bool)->Option.getOr(false)
|
|
269
|
+
|
|
270
|
+
let itemsPage = args =>
|
|
271
|
+
Eng.itemsPage(~readModelName=itemsName, ~subIdField="seq", ~id="order-1", ~argsDict=argsOf(args))
|
|
272
|
+
|
|
273
|
+
describe("QueryEnginePostgres itemsPage (sub-id keyset)", () => {
|
|
274
|
+
testPromise("bare → all lines ASC, no next page", async () => {
|
|
275
|
+
let r = await itemsPage([])
|
|
276
|
+
expect(r->itemSeqs)->toEqual(["a", "b", "c", "d", "e"])
|
|
277
|
+
expect(pageInfoBool(r, "hasNextPage"))->toBe(false)
|
|
278
|
+
})
|
|
279
|
+
testPromise("first:2 → [a,b], hasNextPage", async () => {
|
|
280
|
+
let r = await itemsPage([("first", JSON.Encode.int(2))])
|
|
281
|
+
expect(r->itemSeqs)->toEqual(["a", "b"])
|
|
282
|
+
expect(pageInfoBool(r, "hasNextPage"))->toBe(true)
|
|
283
|
+
})
|
|
284
|
+
testPromise("first:2 after b → [c,d]", async () => {
|
|
285
|
+
let r = await itemsPage([("first", JSON.Encode.int(2)), ("after", JSON.Encode.string(cur("b")))])
|
|
286
|
+
expect(r->itemSeqs)->toEqual(["c", "d"])
|
|
287
|
+
})
|
|
288
|
+
testPromise("order DESC → [e..a]", async () => {
|
|
289
|
+
let r = await itemsPage([("filter", filterOf([("order", JSON.Encode.string("DESC"))]))])
|
|
290
|
+
expect(r->itemSeqs)->toEqual(["e", "d", "c", "b", "a"])
|
|
291
|
+
})
|
|
292
|
+
testPromise("filter eq c → [c]", async () => {
|
|
293
|
+
let r = await itemsPage([("filter", filterOf([("eq", JSON.Encode.string("c"))]))])
|
|
294
|
+
expect(r->itemSeqs)->toEqual(["c"])
|
|
295
|
+
})
|
|
296
|
+
testPromise("filter from b to d → [b,c,d]", async () => {
|
|
297
|
+
let r = await itemsPage([
|
|
298
|
+
("filter", filterOf([("from", JSON.Encode.string("b")), ("to", JSON.Encode.string("d"))])),
|
|
299
|
+
])
|
|
300
|
+
expect(r->itemSeqs)->toEqual(["b", "c", "d"])
|
|
301
|
+
})
|
|
302
|
+
testPromise("prefix c → [c]", async () => {
|
|
303
|
+
let r = await itemsPage([("filter", filterOf([("prefix", JSON.Encode.string("c"))]))])
|
|
304
|
+
expect(r->itemSeqs)->toEqual(["c"])
|
|
305
|
+
})
|
|
306
|
+
testPromise("backward last:2 before d → [b,c] (logical ASC)", async () => {
|
|
307
|
+
let r = await itemsPage([("last", JSON.Encode.int(2)), ("before", JSON.Encode.string(cur("d")))])
|
|
308
|
+
expect(r->itemSeqs)->toEqual(["b", "c"])
|
|
309
|
+
expect(pageInfoBool(r, "hasPreviousPage"))->toBe(true)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
236
312
|
}
|
|
@@ -300,6 +300,142 @@ if (url !== undefined) {
|
|
|
300
300
|
globalThis.expect(items.length).toBe(0);
|
|
301
301
|
});
|
|
302
302
|
});
|
|
303
|
+
let itemsName = "itemsparity";
|
|
304
|
+
let mkLine = seq => {
|
|
305
|
+
let o = {};
|
|
306
|
+
o["id"] = "order-1";
|
|
307
|
+
o["seq"] = seq;
|
|
308
|
+
return o;
|
|
309
|
+
};
|
|
310
|
+
let seqs = [
|
|
311
|
+
"a",
|
|
312
|
+
"b",
|
|
313
|
+
"c",
|
|
314
|
+
"d",
|
|
315
|
+
"e"
|
|
316
|
+
];
|
|
317
|
+
globalThis.beforeAll(async () => {
|
|
318
|
+
let ops = QueryDbStorage_Postgres$ReventlessPostgres.makeOperations(pool, itemsName, [], "seq");
|
|
319
|
+
for (let i = 0, i_finish = seqs.length; i < i_finish; ++i) {
|
|
320
|
+
await ops.save("order-1", mkLine(seqs[i]), "Any", undefined);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
let itemSeqs = conn => Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(conn, "edges"), Stdlib_JSON.Decode.array), []), e => Stdlib_Option.flatMap(field(e, "node"), n => str(n, "seq")));
|
|
324
|
+
let pageInfoBool = (conn, key) => Stdlib_Option.getOr(Stdlib_Option.flatMap(field(Stdlib_Option.getOr(field(conn, "pageInfo"), null), key), Stdlib_JSON.Decode.bool), false);
|
|
325
|
+
let itemsPage = args => Eng.itemsPage(itemsName, "seq", "order-1", Object.fromEntries(args));
|
|
326
|
+
globalThis.describe("QueryEnginePostgres itemsPage (sub-id keyset)", () => {
|
|
327
|
+
globalThis.test("bare → all lines ASC, no next page", async () => {
|
|
328
|
+
let r = await itemsPage([]);
|
|
329
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
330
|
+
"a",
|
|
331
|
+
"b",
|
|
332
|
+
"c",
|
|
333
|
+
"d",
|
|
334
|
+
"e"
|
|
335
|
+
]);
|
|
336
|
+
globalThis.expect(pageInfoBool(r, "hasNextPage")).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
globalThis.test("first:2 → [a,b], hasNextPage", async () => {
|
|
339
|
+
let r = await itemsPage([[
|
|
340
|
+
"first",
|
|
341
|
+
2
|
|
342
|
+
]]);
|
|
343
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
344
|
+
"a",
|
|
345
|
+
"b"
|
|
346
|
+
]);
|
|
347
|
+
globalThis.expect(pageInfoBool(r, "hasNextPage")).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
globalThis.test("first:2 after b → [c,d]", async () => {
|
|
350
|
+
let r = await itemsPage([
|
|
351
|
+
[
|
|
352
|
+
"first",
|
|
353
|
+
2
|
|
354
|
+
],
|
|
355
|
+
[
|
|
356
|
+
"after",
|
|
357
|
+
QueryDbListQuery$ReventlessCore.encodeCursor("b")
|
|
358
|
+
]
|
|
359
|
+
]);
|
|
360
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
361
|
+
"c",
|
|
362
|
+
"d"
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
globalThis.test("order DESC → [e..a]", async () => {
|
|
366
|
+
let r = await itemsPage([[
|
|
367
|
+
"filter",
|
|
368
|
+
Object.fromEntries([[
|
|
369
|
+
"order",
|
|
370
|
+
"DESC"
|
|
371
|
+
]])
|
|
372
|
+
]]);
|
|
373
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
374
|
+
"e",
|
|
375
|
+
"d",
|
|
376
|
+
"c",
|
|
377
|
+
"b",
|
|
378
|
+
"a"
|
|
379
|
+
]);
|
|
380
|
+
});
|
|
381
|
+
globalThis.test("filter eq c → [c]", async () => {
|
|
382
|
+
let r = await itemsPage([[
|
|
383
|
+
"filter",
|
|
384
|
+
Object.fromEntries([[
|
|
385
|
+
"eq",
|
|
386
|
+
"c"
|
|
387
|
+
]])
|
|
388
|
+
]]);
|
|
389
|
+
globalThis.expect(itemSeqs(r)).toEqual(["c"]);
|
|
390
|
+
});
|
|
391
|
+
globalThis.test("filter from b to d → [b,c,d]", async () => {
|
|
392
|
+
let r = await itemsPage([[
|
|
393
|
+
"filter",
|
|
394
|
+
Object.fromEntries([
|
|
395
|
+
[
|
|
396
|
+
"from",
|
|
397
|
+
"b"
|
|
398
|
+
],
|
|
399
|
+
[
|
|
400
|
+
"to",
|
|
401
|
+
"d"
|
|
402
|
+
]
|
|
403
|
+
])
|
|
404
|
+
]]);
|
|
405
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
406
|
+
"b",
|
|
407
|
+
"c",
|
|
408
|
+
"d"
|
|
409
|
+
]);
|
|
410
|
+
});
|
|
411
|
+
globalThis.test("prefix c → [c]", async () => {
|
|
412
|
+
let r = await itemsPage([[
|
|
413
|
+
"filter",
|
|
414
|
+
Object.fromEntries([[
|
|
415
|
+
"prefix",
|
|
416
|
+
"c"
|
|
417
|
+
]])
|
|
418
|
+
]]);
|
|
419
|
+
globalThis.expect(itemSeqs(r)).toEqual(["c"]);
|
|
420
|
+
});
|
|
421
|
+
globalThis.test("backward last:2 before d → [b,c] (logical ASC)", async () => {
|
|
422
|
+
let r = await itemsPage([
|
|
423
|
+
[
|
|
424
|
+
"last",
|
|
425
|
+
2
|
|
426
|
+
],
|
|
427
|
+
[
|
|
428
|
+
"before",
|
|
429
|
+
QueryDbListQuery$ReventlessCore.encodeCursor("d")
|
|
430
|
+
]
|
|
431
|
+
]);
|
|
432
|
+
globalThis.expect(itemSeqs(r)).toEqual([
|
|
433
|
+
"b",
|
|
434
|
+
"c"
|
|
435
|
+
]);
|
|
436
|
+
globalThis.expect(pageInfoBool(r, "hasPreviousPage")).toBe(true);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
303
439
|
} else {
|
|
304
440
|
globalThis.test("Postgres list push-down parity (skipped — set PG_URL to run)", () => {
|
|
305
441
|
globalThis.expect(true).toBe(true);
|