@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/compiler-info.json +1 -1
  4. package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
  5. package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
  6. package/lib/bs/src/QueryEnginePostgres.ast +0 -0
  7. package/lib/bs/src/QueryEnginePostgres.res +84 -0
  8. package/lib/bs/src/QueryEnginePostgres.res.mjs +74 -0
  9. package/lib/bs/tests/QueryEnginePostgresListPageTest-ReventlessPostgres.cmi +0 -0
  10. package/lib/bs/tests/QueryEnginePostgresListPageTest-ReventlessPostgres.cmt +0 -0
  11. package/lib/bs/tests/QueryEnginePostgresListPageTest.ast +0 -0
  12. package/lib/bs/tests/QueryEnginePostgresListPageTest.res +76 -0
  13. package/lib/bs/tests/QueryEnginePostgresListPageTest.res.mjs +136 -0
  14. package/lib/ocaml/.compiler.log +2 -2
  15. package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
  16. package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
  17. package/lib/ocaml/QueryEnginePostgres.ast +0 -0
  18. package/lib/ocaml/QueryEnginePostgres.res +84 -0
  19. package/lib/ocaml/QueryEnginePostgresListPageTest-ReventlessPostgres.cmi +0 -0
  20. package/lib/ocaml/QueryEnginePostgresListPageTest-ReventlessPostgres.cmt +0 -0
  21. package/lib/ocaml/QueryEnginePostgresListPageTest.ast +0 -0
  22. package/lib/ocaml/QueryEnginePostgresListPageTest.res +76 -0
  23. package/package.json +2 -2
  24. package/src/QueryEnginePostgres.res +84 -0
  25. package/src/QueryEnginePostgres.res.mjs +74 -0
  26. package/tests/QueryEnginePostgresListPageTest.res +76 -0
  27. 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
@@ -1,2 +1,2 @@
1
- #Start(1783298008858)
2
- #Done(1783298010813)
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": "1783298010821"
7
+ "generated_at": "1783334806766"
8
8
  }
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
  }
@@ -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);
@@ -1,2 +1,2 @@
1
- #Start(1783298008858)
2
- #Done(1783298010813)
1
+ #Start(1783334804845)
2
+ #Done(1783334806753)
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
  }
@@ -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.5",
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);