@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.
- package/CHANGELOG.md +14 -0
- package/docker-compose.postgres.yml +21 -0
- package/package.json +6 -5
- package/scripts/run-pg-integration-tests.sh +58 -0
- package/src/Platform.res +37 -0
- package/src/Platform.res.mjs +41 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
- package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
- package/src/adapter/Postgres/PgConnection.res +70 -10
- package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
- package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
- package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res +11 -13
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +5 -24
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +51 -3
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +48 -18
- package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
- package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
- package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +1 -1
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +31 -7
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +16 -10
- package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
- package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +1 -1
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
- package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
- package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
- package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
- package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
- package/src/util/Util_Bundle.res +71 -1
- package/src/util/Util_Bundle.res.mjs +49 -1
- package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
- package/tests/PgConnectionJsonTest.res +84 -0
- package/tests/PgConnectionJsonTest.res.mjs +68 -0
- package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
- package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
- package/tests/PgPipeline_IntegrationTest.res +3 -1
- package/tests/PgQueryResolver_LambdaTest.res +59 -0
- package/tests/PgQueryResolver_LambdaTest.res.mjs +95 -16
- package/tests/StateTopicPublishTest.res +162 -0
- package/tests/StateTopicPublishTest.res.mjs +158 -0
- package/tests/Util_BundleEsmLoaderTest.res +57 -0
- 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
|
-
//
|
|
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;
|