@reventlessdev/reventless-aws 3.0.0-alpha.177 → 3.0.0-alpha.178

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 (68) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/docker-compose.postgres.yml +21 -0
  3. package/package.json +6 -5
  4. package/scripts/run-pg-integration-tests.sh +58 -0
  5. package/src/Platform.res +37 -0
  6. package/src/Platform.res.mjs +41 -4
  7. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
  8. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
  9. package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
  10. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
  11. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
  12. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
  13. package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
  14. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
  15. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
  16. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
  17. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
  18. package/src/adapter/Postgres/PgConnection.res +70 -10
  19. package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
  20. package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
  21. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
  22. package/src/adapter/QueryDb/PgQueryResolver_Builder.res +11 -13
  23. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +5 -24
  24. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +51 -3
  25. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +48 -18
  26. package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
  27. package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
  28. package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +1 -1
  29. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +31 -7
  30. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +16 -10
  31. package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
  32. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
  33. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +1 -1
  34. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
  35. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
  36. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
  37. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
  38. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
  39. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
  40. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
  41. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
  42. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
  43. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
  44. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
  45. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
  46. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
  47. package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
  48. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
  49. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
  50. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
  51. package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
  52. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
  53. package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
  54. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
  55. package/src/util/Util_Bundle.res +71 -1
  56. package/src/util/Util_Bundle.res.mjs +49 -1
  57. package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
  58. package/tests/PgConnectionJsonTest.res +84 -0
  59. package/tests/PgConnectionJsonTest.res.mjs +68 -0
  60. package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
  61. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
  62. package/tests/PgPipeline_IntegrationTest.res +3 -1
  63. package/tests/PgQueryResolver_LambdaTest.res +59 -0
  64. package/tests/PgQueryResolver_LambdaTest.res.mjs +95 -16
  65. package/tests/StateTopicPublishTest.res +162 -0
  66. package/tests/StateTopicPublishTest.res.mjs +158 -0
  67. package/tests/Util_BundleEsmLoaderTest.res +57 -0
  68. package/tests/Util_BundleEsmLoaderTest.res.mjs +44 -0
@@ -0,0 +1,84 @@
1
+ // Rung-1 guard for the `pgConnection` HANDLER_CONFIG serialization (A3/B/C).
2
+ //
3
+ // `PgConnection.connectionConfigToJson` is the single producer of the object
4
+ // every Postgres entry point reads at cold start (`config.pgConnection.*` →
5
+ // `PgRuntime.poolFor`). The field set is an untyped cross-language contract — a
6
+ // dropped/renamed/retyped field here only fails at runtime in the deployed
7
+ // Lambda, which no compile catches. These tests pin the exact shape so a drift
8
+ // fails in CI instead. All seven deploy-time builders now route through this
9
+ // helper, so pinning it pins them all.
10
+
11
+ open JestGlobals
12
+
13
+ let cc: PgConnection.connectionConfig = {
14
+ host: "db.example.eu-west-1.rds.amazonaws.com",
15
+ port: 5432,
16
+ database: "reventless",
17
+ username: "reventless_admin",
18
+ secretArn: "arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds!db-abc-123",
19
+ }
20
+
21
+ let obj = json => json->JSON.Decode.object->Option.getOrThrow
22
+ let field = (json, key) => json->obj->Dict.get(key)
23
+
24
+ describe("PgConnection.connectionConfigToJson", () => {
25
+ testSync("emits exactly the five connection fields with correct types", () => {
26
+ let json = cc->PgConnection.connectionConfigToJson
27
+ // Exact key set — no extra keys, none missing (the cold-start contract).
28
+ expect(json->obj->Dict.keysToArray->Array.toSorted(String.compare))->toEqual([
29
+ "database",
30
+ "host",
31
+ "port",
32
+ "secretArn",
33
+ "username",
34
+ ])
35
+ expect(json->field("host"))->toEqual(Some(JSON.Encode.string(cc.host)))
36
+ expect(json->field("database"))->toEqual(Some(JSON.Encode.string(cc.database)))
37
+ expect(json->field("username"))->toEqual(Some(JSON.Encode.string(cc.username)))
38
+ expect(json->field("secretArn"))->toEqual(Some(JSON.Encode.string(cc.secretArn)))
39
+ // port MUST be a JSON number (poolFor reads it as an int), not a string.
40
+ expect(json->field("port"))->toEqual(Some(JSON.Encode.float(5432.)))
41
+ })
42
+
43
+ testSync("omits lockStrategy when not given (classic / QueryDb / migration paths)", () =>
44
+ expect(cc->PgConnection.connectionConfigToJson->field("lockStrategy"))->toEqual(None)
45
+ )
46
+
47
+ testSync("adds lockStrategy=\"AdvisoryLocks\" as the polyvariant string name", () =>
48
+ expect(
49
+ cc
50
+ ->PgConnection.connectionConfigToJson(~lockStrategy=#AdvisoryLocks)
51
+ ->field("lockStrategy"),
52
+ )->toEqual(Some(JSON.Encode.string("AdvisoryLocks")))
53
+ )
54
+
55
+ testSync("adds lockStrategy=\"RowLocks\" as the polyvariant string name", () =>
56
+ expect(
57
+ cc
58
+ ->PgConnection.connectionConfigToJson(~lockStrategy=#RowLocks)
59
+ ->field("lockStrategy"),
60
+ )->toEqual(Some(JSON.Encode.string("RowLocks")))
61
+ )
62
+
63
+ testSync("with lockStrategy the object has exactly six keys", () =>
64
+ expect(
65
+ cc
66
+ ->PgConnection.connectionConfigToJson(~lockStrategy=#RowLocks)
67
+ ->obj
68
+ ->Dict.keysToArray
69
+ ->Array.length,
70
+ )->toBe(6)
71
+ )
72
+
73
+ testSync("round-trips through JSON.parse as the entry points do", () => {
74
+ // The entry point does `JSON.parse(HANDLER_CONFIG).pgConnection` — prove the
75
+ // stringify→parse survives and still exposes the fields poolFor reads.
76
+ let parsed =
77
+ `{"pgConnection":${cc->PgConnection.connectionConfigToJson->JSON.stringify}}`
78
+ ->JSON.parseOrThrow
79
+ ->field("pgConnection")
80
+ ->Option.getOrThrow
81
+ expect(parsed->field("host"))->toEqual(Some(JSON.Encode.string(cc.host)))
82
+ expect(parsed->field("secretArn"))->toEqual(Some(JSON.Encode.string(cc.secretArn)))
83
+ })
84
+ })
@@ -0,0 +1,68 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
6
+ import * as PgConnection$ReventlessAws from "../src/adapter/Postgres/PgConnection.res.mjs";
7
+
8
+ let cc = {
9
+ host: "db.example.eu-west-1.rds.amazonaws.com",
10
+ port: 5432,
11
+ database: "reventless",
12
+ username: "reventless_admin",
13
+ secretArn: "arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds!db-abc-123"
14
+ };
15
+
16
+ function obj(json) {
17
+ return Stdlib_Option.getOrThrow(Stdlib_JSON.Decode.object(json), undefined);
18
+ }
19
+
20
+ function field(json, key) {
21
+ return obj(json)[key];
22
+ }
23
+
24
+ globalThis.describe("PgConnection.connectionConfigToJson", () => {
25
+ globalThis.test("emits exactly the five connection fields with correct types", () => {
26
+ let json = PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc);
27
+ globalThis.expect(Object.keys(obj(json)).toSorted(Primitive_string.compare)).toEqual([
28
+ "database",
29
+ "host",
30
+ "port",
31
+ "secretArn",
32
+ "username"
33
+ ]);
34
+ globalThis.expect(obj(json)["host"]).toEqual("db.example.eu-west-1.rds.amazonaws.com");
35
+ globalThis.expect(obj(json)["database"]).toEqual("reventless");
36
+ globalThis.expect(obj(json)["username"]).toEqual("reventless_admin");
37
+ globalThis.expect(obj(json)["secretArn"]).toEqual("arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds!db-abc-123");
38
+ globalThis.expect(obj(json)["port"]).toEqual(5432);
39
+ });
40
+ globalThis.test("omits lockStrategy when not given (classic / QueryDb / migration paths)", () => {
41
+ let json = PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc);
42
+ globalThis.expect(obj(json)["lockStrategy"]).toEqual(undefined);
43
+ });
44
+ globalThis.test("adds lockStrategy=\"AdvisoryLocks\" as the polyvariant string name", () => {
45
+ let json = PgConnection$ReventlessAws.connectionConfigToJson("AdvisoryLocks", cc);
46
+ globalThis.expect(obj(json)["lockStrategy"]).toEqual("AdvisoryLocks");
47
+ });
48
+ globalThis.test("adds lockStrategy=\"RowLocks\" as the polyvariant string name", () => {
49
+ let json = PgConnection$ReventlessAws.connectionConfigToJson("RowLocks", cc);
50
+ globalThis.expect(obj(json)["lockStrategy"]).toEqual("RowLocks");
51
+ });
52
+ globalThis.test("with lockStrategy the object has exactly six keys", () => {
53
+ globalThis.expect(Object.keys(obj(PgConnection$ReventlessAws.connectionConfigToJson("RowLocks", cc))).length).toBe(6);
54
+ });
55
+ globalThis.test("round-trips through JSON.parse as the entry points do", () => {
56
+ let json = JSON.parse(`{"pgConnection":` + JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc)) + `}`);
57
+ let parsed = Stdlib_Option.getOrThrow(obj(json)["pgConnection"], undefined);
58
+ globalThis.expect(obj(parsed)["host"]).toEqual("db.example.eu-west-1.rds.amazonaws.com");
59
+ globalThis.expect(obj(parsed)["secretArn"]).toEqual("arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds!db-abc-123");
60
+ });
61
+ });
62
+
63
+ export {
64
+ cc,
65
+ obj,
66
+ field,
67
+ }
68
+ /* Not a pure module */
@@ -0,0 +1,82 @@
1
+ // Rung-1 integration guard for the A3 schema-migration Lambda entry point.
2
+ //
3
+ // Drives the real `PgMigrationEntryPoint.runMigration` — the orchestration the
4
+ // deployed one-shot migration Lambda runs (parse HANDLER_CONFIG → build pool →
5
+ // PgSchema.ensureSchema). The pool source is injected so this exercises the
6
+ // whole path against a real Postgres WITHOUT the Secrets Manager round-trip
7
+ // (that AWS boundary is the only remaining unvalidated piece). Proves the
8
+ // migration leaves a queryable schema and is safely re-runnable.
9
+ //
10
+ // Run via `pnpm run test:integration:pg` (boots a Postgres sidecar, runs the PG
11
+ // suites serially, tears down). Excluded from the default parallel `pnpm test`.
12
+ // The guard test (no pgConnection → throws) needs no DB; the live path is
13
+ // skipped unless PG_URL is set (the script exports it).
14
+
15
+ open JestGlobals
16
+
17
+ @val external processEnv: dict<string> = "process.env"
18
+
19
+ // Import the .mjs entry point and call runMigration with the pool injected.
20
+ // Returns "ok" on success. A minimal pgConnection satisfies the guard; the real
21
+ // fields are ignored because `makePool` is injected.
22
+ let runMigrationWithPool: (
23
+ ReventlessPostgres.PgDriver.pool,
24
+ ) => promise<string> = %raw(`
25
+ async (pool) => {
26
+ const { runMigration } = await import(
27
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs"
28
+ );
29
+ const config = {
30
+ pgConnection: {
31
+ host: "ignored", port: 5432, database: "ignored",
32
+ username: "ignored", secretArn: "ignored",
33
+ },
34
+ };
35
+ return await runMigration(config, { makePool: () => pool });
36
+ }
37
+ `)
38
+
39
+ // runMigration with an empty config — the guard must reject a HANDLER_CONFIG
40
+ // that carries no pgConnection (a misconfigured deploy), rather than silently
41
+ // building a bad pool.
42
+ let runMigrationMissingConfigThrows: unit => promise<bool> = %raw(`
43
+ async () => {
44
+ const { runMigration } = await import(
45
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs"
46
+ );
47
+ try { await runMigration({}, {}); return false; }
48
+ catch { return true; }
49
+ }
50
+ `)
51
+
52
+ describe("PgMigrationEntryPoint.runMigration", () => {
53
+ testPromise("rejects a HANDLER_CONFIG with no pgConnection", async () => {
54
+ let threw = await runMigrationMissingConfigThrows()
55
+ expect(threw)->toBe(true)
56
+ })
57
+
58
+ switch processEnv->Dict.get("PG_URL") {
59
+ | None =>
60
+ testSync("live migration (skipped — set PG_URL to run)", () => expect(true)->toBe(true))
61
+ | Some(url) =>
62
+ let pool = ReventlessPostgres.PgDriver.makePool({connectionString: url})
63
+ afterAll(() => {
64
+ let _ = pool->ReventlessPostgres.PgDriver.endPool
65
+ })
66
+
67
+ testPromise("runs ensureSchema, leaving both event logs queryable", async () => {
68
+ let _ = await runMigrationWithPool(pool)
69
+ // countAll queries the tables ensureSchema creates — it resolves (>= 0)
70
+ // only if `event_log` / `dcb_event` exist, and throws otherwise.
71
+ let classicCount = await ReventlessPostgres.EventLogStorage_Postgres.countAll(pool)
72
+ let dcbCount = await ReventlessPostgres.DcbEventLogStorage_Postgres.countAll(pool)
73
+ expect(classicCount >= 0 && dcbCount >= 0)->toBe(true)
74
+ })
75
+
76
+ testPromise("is idempotent — a second run does not throw", async () => {
77
+ let _ = await runMigrationWithPool(pool)
78
+ let again = await runMigrationWithPool(pool)
79
+ expect(again)->toBe("ok")
80
+ })
81
+ }
82
+ })
@@ -0,0 +1,63 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as PgDriver$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/PgDriver.res.mjs";
4
+ import * as EventLogStorage_Postgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/EventLogStorage_Postgres.res.mjs";
5
+ import * as DcbEventLogStorage_Postgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/DcbEventLogStorage_Postgres.res.mjs";
6
+
7
+ let runMigrationWithPool = (async (pool) => {
8
+ const { runMigration } = await import(
9
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs"
10
+ );
11
+ const config = {
12
+ pgConnection: {
13
+ host: "ignored", port: 5432, database: "ignored",
14
+ username: "ignored", secretArn: "ignored",
15
+ },
16
+ };
17
+ return await runMigration(config, { makePool: () => pool });
18
+ });
19
+
20
+ let runMigrationMissingConfigThrows = (async () => {
21
+ const { runMigration } = await import(
22
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/PgMigrationEntryPoint.mjs"
23
+ );
24
+ try { await runMigration({}, {}); return false; }
25
+ catch { return true; }
26
+ });
27
+
28
+ globalThis.describe("PgMigrationEntryPoint.runMigration", () => {
29
+ globalThis.test("rejects a HANDLER_CONFIG with no pgConnection", async () => {
30
+ let threw = await runMigrationMissingConfigThrows();
31
+ globalThis.expect(threw).toBe(true);
32
+ });
33
+ let url = process.env["PG_URL"];
34
+ if (url !== undefined) {
35
+ let pool = PgDriver$ReventlessPostgres.makePool({
36
+ connectionString: url
37
+ });
38
+ globalThis.afterAll(() => {
39
+ PgDriver$ReventlessPostgres.endPool(pool);
40
+ });
41
+ globalThis.test("runs ensureSchema, leaving both event logs queryable", async () => {
42
+ await runMigrationWithPool(pool);
43
+ let classicCount = await EventLogStorage_Postgres$ReventlessPostgres.countAll(pool);
44
+ let dcbCount = await DcbEventLogStorage_Postgres$ReventlessPostgres.countAll(pool);
45
+ globalThis.expect(classicCount >= 0 && dcbCount >= 0).toBe(true);
46
+ });
47
+ globalThis.test("is idempotent — a second run does not throw", async () => {
48
+ await runMigrationWithPool(pool);
49
+ let again = await runMigrationWithPool(pool);
50
+ globalThis.expect(again).toBe("ok");
51
+ });
52
+ return;
53
+ }
54
+ globalThis.test("live migration (skipped — set PG_URL to run)", () => {
55
+ globalThis.expect(true).toBe(true);
56
+ });
57
+ });
58
+
59
+ export {
60
+ runMigrationWithPool,
61
+ runMigrationMissingConfigThrows,
62
+ }
63
+ /* runMigrationWithPool Not a pure module */
@@ -1,6 +1,8 @@
1
1
  // End-to-end Postgres projection pipeline (B1 + B3.0 + B3.1 composed), minus AWS.
2
2
  //
3
- // Skipped unless PG_URL is set. Exercises the full deployed classic path against
3
+ // Run via `pnpm run test:integration:pg` (boots a Postgres sidecar, runs the PG
4
+ // suites serially, tears down). Excluded from the default parallel `pnpm test`;
5
+ // self-skips unless PG_URL is set. Exercises the full deployed classic path against
4
6
  // a real database:
5
7
  // classic append (`event_log`) → change-feed relay (`relayClassicWithPool`,
6
8
  // emitting the EventCollector `{id, meta, event}` bodies) → feed-queue SQS
@@ -293,6 +293,65 @@ describe("PgQueryResolver_Lambda.dispatch", () => {
293
293
  expect(r)->toBe(JSON.Encode.null)
294
294
  })
295
295
 
296
+ // ── auth-table pipeline (group-restricted index) ──────────────────────────
297
+ // Auth table (another read model) maps index value → { ownerId: <username> }.
298
+ let authStore = Dict.fromArray([
299
+ ("active", JSON.Encode.object(Dict.fromArray([("ownerId", JSON.Encode.string("alice"))]))),
300
+ ])
301
+ let authOps: QueryDb_Adapter.operations = {
302
+ load: async id => Ok(authStore->Dict.get(id)->Option.mapOr([], r => [r])),
303
+ loadStream: Obj.magic(0),
304
+ save: Obj.magic(0),
305
+ saveBatch: Obj.magic(0),
306
+ count: Obj.magic(0),
307
+ delete: Obj.magic(0),
308
+ deleteBatch: Obj.magic(0),
309
+ }
310
+ let authBinding = {...makeBinding(), PgQueryResolver_Lambda.ops: authOps}
311
+ let lookupAuth = name => name == "AuthTable" ? Some(authBinding) : None
312
+ let ident = (~username, ~groups): Reventless.Identity.t => {
313
+ userId: username,
314
+ username,
315
+ groups,
316
+ provider: InMemory,
317
+ }
318
+ let authIndexPayload = (~username, ~groups): PgQueryResolver_Lambda.payload => {
319
+ readModelName: "Things",
320
+ kind: "index",
321
+ index: "byStatus",
322
+ authTable: "AuthTable",
323
+ authGroup: "Owner",
324
+ arguments: objArgs([("byStatus", JSON.Encode.string("active"))]),
325
+ identity: ident(~username, ~groups),
326
+ }
327
+
328
+ testPromise("auth index: group member who owns → results", async () => {
329
+ let r = await PgQueryResolver_Lambda.dispatch(
330
+ ~binding=makeBinding(),
331
+ ~lookupBinding=lookupAuth,
332
+ ~payload=authIndexPayload(~username="alice", ~groups=["Owner"]),
333
+ )
334
+ expect(r->ids)->toEqual(["p-1", "p-3"])
335
+ })
336
+
337
+ testPromise("auth index: group member who does NOT own → empty", async () => {
338
+ let r = await PgQueryResolver_Lambda.dispatch(
339
+ ~binding=makeBinding(),
340
+ ~lookupBinding=lookupAuth,
341
+ ~payload=authIndexPayload(~username="bob", ~groups=["Owner"]),
342
+ )
343
+ expect(r->JSON.Decode.array->Option.getOr([])->Array.length)->toBe(0)
344
+ })
345
+
346
+ testPromise("auth index: non-member passes through → results", async () => {
347
+ let r = await PgQueryResolver_Lambda.dispatch(
348
+ ~binding=makeBinding(),
349
+ ~lookupBinding=lookupAuth,
350
+ ~payload=authIndexPayload(~username="bob", ~groups=[]),
351
+ )
352
+ expect(r->ids)->toEqual(["p-1", "p-3"])
353
+ })
354
+
296
355
  testPromise("unmapped kind throws", async () => {
297
356
  let threw = try {
298
357
  let _ = await PgQueryResolver_Lambda.dispatch(
@@ -157,7 +157,7 @@ function ids(arr) {
157
157
 
158
158
  globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
159
159
  globalThis.test("getById returns the item (id-carrying)", async () => {
160
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("getById", undefined, Object.fromEntries([[
160
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
161
161
  "id",
162
162
  "p-2"
163
163
  ]]), undefined));
@@ -165,14 +165,14 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
165
165
  globalThis.expect(str(r, "name")).toEqual("Bravo");
166
166
  });
167
167
  globalThis.test("getById miss → null", async () => {
168
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("getById", undefined, Object.fromEntries([[
168
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
169
169
  "id",
170
170
  "absent"
171
171
  ]]), undefined));
172
172
  globalThis.expect(r).toBe(null);
173
173
  });
174
174
  globalThis.test("byIds returns matches, drops missing", async () => {
175
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("byIds", undefined, Object.fromEntries([[
175
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
176
176
  "ids",
177
177
  [
178
178
  "p-1",
@@ -186,7 +186,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
186
186
  ]);
187
187
  });
188
188
  globalThis.test("index routes to indexLookup on the idField", async () => {
189
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("index", "byStatus", Object.fromEntries([[
189
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("index", "byStatus", Object.fromEntries([[
190
190
  "byStatus",
191
191
  "active"
192
192
  ]]), undefined));
@@ -197,13 +197,13 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
197
197
  });
198
198
  globalThis.test("list with push-down → returns the connection listPage gives", async () => {
199
199
  listPageReturn.contents = "PUSHED";
200
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("list", undefined, undefined, undefined));
200
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("list", undefined, undefined, undefined));
201
201
  listPageReturn.contents = undefined;
202
202
  globalThis.expect(r).toEqual("PUSHED");
203
203
  });
204
204
  globalThis.test("list without push-down → falls back to the shared spec", async () => {
205
205
  listPageReturn.contents = undefined;
206
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("list", undefined, undefined, undefined));
206
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("list", undefined, undefined, undefined));
207
207
  let edgeIds = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(r, "edges"), Stdlib_JSON.Decode.array), []), e => Stdlib_Option.flatMap(field(e, "node"), n => str(n, "id")));
208
208
  globalThis.expect(edgeIds).toEqual([
209
209
  "p-1",
@@ -212,14 +212,14 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
212
212
  ]);
213
213
  });
214
214
  globalThis.test("items with subId routes to itemsPage (id echoed)", async () => {
215
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, "seq", undefined), mkPayload("items", undefined, Object.fromEntries([[
215
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, "seq", undefined), undefined, mkPayload("items", undefined, Object.fromEntries([[
216
216
  "id",
217
217
  "p-2"
218
218
  ]]), undefined));
219
219
  globalThis.expect(str(r, "itemsFor")).toEqual("p-2");
220
220
  });
221
221
  globalThis.test("items without subId → empty connection", async () => {
222
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("items", undefined, Object.fromEntries([[
222
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("items", undefined, Object.fromEntries([[
223
223
  "id",
224
224
  "p-2"
225
225
  ]]), undefined));
@@ -227,14 +227,14 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
227
227
  });
228
228
  globalThis.test("authorization DenyAll → empty shape per kind", async () => {
229
229
  let b = makeBinding("DenyAll", undefined, undefined);
230
- let single = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, mkPayload("getById", undefined, Object.fromEntries([[
230
+ let single = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("getById", undefined, Object.fromEntries([[
231
231
  "id",
232
232
  "p-1"
233
233
  ]]), undefined));
234
234
  globalThis.expect(single).toBe(null);
235
- let list = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, mkPayload("list", undefined, undefined, undefined));
235
+ let list = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("list", undefined, undefined, undefined));
236
236
  globalThis.expect(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(list, "edges"), Stdlib_JSON.Decode.array), []).length).toBe(0);
237
- let byIds = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, mkPayload("byIds", undefined, Object.fromEntries([[
237
+ let byIds = await PgQueryResolver_Lambda$ReventlessAws.dispatch(b, undefined, mkPayload("byIds", undefined, Object.fromEntries([[
238
238
  "ids",
239
239
  []
240
240
  ]]), undefined));
@@ -245,7 +245,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
245
245
  TAG: "Deny",
246
246
  _0: "nope"
247
247
  }));
248
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("getById", undefined, Object.fromEntries([[
248
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
249
249
  "id",
250
250
  "p-1"
251
251
  ]]), undefined));
@@ -271,7 +271,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
271
271
  arguments: payload_arguments,
272
272
  identity: Identity$Reventless.anonymous
273
273
  };
274
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), payload);
274
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
275
275
  globalThis.expect(str(r, "id")).toEqual("p-2");
276
276
  });
277
277
  globalThis.test("resolveOne multi via target index → array", async () => {
@@ -297,7 +297,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
297
297
  arguments: payload_arguments,
298
298
  identity: Identity$Reventless.anonymous
299
299
  };
300
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), payload);
300
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
301
301
  globalThis.expect(ids(r)).toEqual([
302
302
  "p-1",
303
303
  "p-3"
@@ -324,7 +324,7 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
324
324
  arguments: payload_arguments,
325
325
  identity: Identity$Reventless.anonymous
326
326
  };
327
- let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), payload);
327
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, payload);
328
328
  globalThis.expect(ids(r)).toEqual([
329
329
  "p-1",
330
330
  "p-3"
@@ -361,10 +361,89 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
361
361
  let r = await PgQueryResolver_Lambda$ReventlessAws.handler(payload, null);
362
362
  globalThis.expect(r).toBe(null);
363
363
  });
364
+ let authStore = Object.fromEntries([[
365
+ "active",
366
+ Object.fromEntries([[
367
+ "ownerId",
368
+ "alice"
369
+ ]])
370
+ ]]);
371
+ let authOps_load = async id => ({
372
+ TAG: "Ok",
373
+ _0: Stdlib_Option.mapOr(authStore[id], [], r => [r])
374
+ });
375
+ let authOps = {
376
+ load: authOps_load,
377
+ loadStream: 0,
378
+ save: 0,
379
+ saveBatch: 0,
380
+ count: 0,
381
+ delete: 0,
382
+ deleteBatch: 0
383
+ };
384
+ let init = makeBinding(undefined, undefined, undefined);
385
+ let authBinding_pushdowns = init.pushdowns;
386
+ let authBinding_indexes = init.indexes;
387
+ let authBinding_subIdField = init.subIdField;
388
+ let authBinding_capability = init.capability;
389
+ let authBinding_labelField = init.labelField;
390
+ let authBinding_includeIdParam = init.includeIdParam;
391
+ let authBinding_authorization = init.authorization;
392
+ let authBinding = {
393
+ ops: authOps,
394
+ pushdowns: authBinding_pushdowns,
395
+ indexes: authBinding_indexes,
396
+ subIdField: authBinding_subIdField,
397
+ capability: authBinding_capability,
398
+ labelField: authBinding_labelField,
399
+ includeIdParam: authBinding_includeIdParam,
400
+ authorization: authBinding_authorization
401
+ };
402
+ let lookupAuth = name => {
403
+ if (name === "AuthTable") {
404
+ return authBinding;
405
+ }
406
+ };
407
+ let ident = (username, groups) => ({
408
+ userId: username,
409
+ username: username,
410
+ groups: groups,
411
+ provider: "InMemory"
412
+ });
413
+ let authIndexPayload = (username, groups) => ({
414
+ readModelName: "Things",
415
+ kind: "index",
416
+ index: "byStatus",
417
+ authTable: "AuthTable",
418
+ authGroup: "Owner",
419
+ arguments: Object.fromEntries([[
420
+ "byStatus",
421
+ "active"
422
+ ]]),
423
+ identity: ident(username, groups)
424
+ });
425
+ globalThis.test("auth index: group member who owns → results", async () => {
426
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("alice", ["Owner"]));
427
+ globalThis.expect(ids(r)).toEqual([
428
+ "p-1",
429
+ "p-3"
430
+ ]);
431
+ });
432
+ globalThis.test("auth index: group member who does NOT own → empty", async () => {
433
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("bob", ["Owner"]));
434
+ globalThis.expect(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(r), []).length).toBe(0);
435
+ });
436
+ globalThis.test("auth index: non-member passes through → results", async () => {
437
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), lookupAuth, authIndexPayload("bob", []));
438
+ globalThis.expect(ids(r)).toEqual([
439
+ "p-1",
440
+ "p-3"
441
+ ]);
442
+ });
364
443
  globalThis.test("unmapped kind throws", async () => {
365
444
  let threw;
366
445
  try {
367
- await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), mkPayload("frobnicate", undefined, undefined, undefined));
446
+ await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("frobnicate", undefined, undefined, undefined));
368
447
  threw = false;
369
448
  } catch (exn) {
370
449
  threw = true;