@reventlessdev/reventless-aws 3.0.0-alpha.175 → 3.0.0-alpha.176

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 (71) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +10 -10
  3. package/src/Platform.res +115 -31
  4. package/src/Platform.res.mjs +217 -44
  5. package/src/adapter/DcbEventLog/DcbBackend.res +2 -2
  6. package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res +4 -1
  7. package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs +1 -1
  8. package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res +14 -4
  9. package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs +19 -14
  10. package/src/adapter/EventLog/EventLogBackend.res +82 -0
  11. package/src/adapter/EventLog/EventLogBackend.res.mjs +53 -0
  12. package/src/adapter/EventLog/EventLogStorage.res +15 -0
  13. package/src/adapter/EventLog/EventLogStorage.res.mjs +20 -1
  14. package/src/adapter/EventLog/EventLogStorage_Postgres.res +37 -0
  15. package/src/adapter/EventLog/EventLogStorage_Postgres.res.mjs +21 -0
  16. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +26 -11
  17. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +15 -6
  18. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +54 -0
  19. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +23 -0
  20. package/src/adapter/Postgres/PgProjectionFeed.res +100 -0
  21. package/src/adapter/Postgres/PgProjectionFeed.res.mjs +54 -0
  22. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +192 -0
  23. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +141 -0
  24. package/src/adapter/QueryDb/QueryDbBackend.res +52 -0
  25. package/src/adapter/QueryDb/QueryDbBackend.res.mjs +44 -0
  26. package/src/adapter/QueryDb/QueryDbResolvers.res +48 -0
  27. package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +17 -1
  28. package/src/adapter/QueryDb/QueryDbStorage.res +44 -0
  29. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +34 -1
  30. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +43 -0
  31. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +21 -0
  32. package/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res +19 -0
  33. package/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs +15 -0
  34. package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +12 -1
  35. package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs +2 -1
  36. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +83 -3
  37. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +46 -3
  38. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +83 -3
  39. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +48 -3
  40. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +130 -5
  41. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +66 -6
  42. package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.mjs +15 -10
  43. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +32 -15
  44. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +26 -12
  45. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +138 -8
  46. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +76 -11
  47. package/src/components/Aggregate_Builder_Single.res +12 -5
  48. package/src/components/Aggregate_Builder_Single.res.mjs +12 -6
  49. package/src/components/Aggregate_Builder_Single_Async.res +12 -5
  50. package/src/components/Aggregate_Builder_Single_Async.res.mjs +12 -6
  51. package/src/components/ReadModel_Builder_NoResolver.res.mjs +1 -1
  52. package/src/components/ReadModel_Builder_NoResolver_Stream.res +13 -4
  53. package/src/components/ReadModel_Builder_NoResolver_Stream.res.mjs +14 -9
  54. package/src/components/ReadModel_Builder_Single.res +14 -5
  55. package/src/components/ReadModel_Builder_Single.res.mjs +15 -12
  56. package/src/components/ReadModel_Builder_Single_Stream.res +14 -5
  57. package/src/components/ReadModel_Builder_Single_Stream.res.mjs +15 -12
  58. package/src/components/StateViewSlice_Builder.res +10 -4
  59. package/src/components/StateViewSlice_Builder.res.mjs +8 -10
  60. package/src/components/StateViewSlice_Builder_Stream.res +10 -4
  61. package/src/components/StateViewSlice_Builder_Stream.res.mjs +8 -10
  62. package/src/plugin/runtime/PluginRuntime_Builder.res +12 -0
  63. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +4 -0
  64. package/tests/EventCollectorChannelStreamRuntimeTest.res +55 -0
  65. package/tests/EventCollectorChannelStreamRuntimeTest.res.mjs +78 -0
  66. package/tests/PgChangeFeedRelay_IntegrationTest.res +115 -4
  67. package/tests/PgChangeFeedRelay_IntegrationTest.res.mjs +84 -0
  68. package/tests/PgPipeline_IntegrationTest.res +208 -0
  69. package/tests/PgPipeline_IntegrationTest.res.mjs +260 -0
  70. package/tests/PgQueryResolver_LambdaTest.res +206 -0
  71. package/tests/PgQueryResolver_LambdaTest.res.mjs +259 -0
@@ -1,10 +1,12 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Pulumi from "@pulumi/pulumi";
3
4
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
4
5
  import * as Util_Bundle$ReventlessAws from "../util/Util_Bundle.res.mjs";
5
- import * as QueryDbStorage_DynamoDb$ReventlessAws from "../adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs";
6
+ import * as QueryDbBackend$ReventlessAws from "../adapter/QueryDb/QueryDbBackend.res.mjs";
7
+ import * as QueryDbStorage$ReventlessAws from "../adapter/QueryDb/QueryDbStorage.res.mjs";
8
+ import * as QueryDbResolvers$ReventlessAws from "../adapter/QueryDb/QueryDbResolvers.res.mjs";
6
9
  import * as StateViewSlice_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs";
7
- import * as QueryDbResolvers_AppSync$ReventlessAws from "../adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs";
8
10
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "../adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs";
9
11
  import * as EventCollectorChannel_DynamoDbStream$ReventlessAws from "../adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs";
10
12
  import * as StateViewSliceRuntime_Builder_Single$ReventlessAws from "../adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs";
@@ -16,11 +18,7 @@ function Make(Api) {
16
18
  extractCorrelationId: RuntimeEnvironment_Lambda$ReventlessAws.extractCorrelationId,
17
19
  asEventHandler: prim => prim,
18
20
  asEffectHandler: prim => prim
19
- })({
20
- make: QueryDbStorage_DynamoDb$ReventlessAws.make
21
- })({
22
- make: QueryDbResolvers_AppSync$ReventlessAws.make
23
- })({
21
+ })(QueryDbStorage$ReventlessAws.Selectable)(QueryDbResolvers$ReventlessAws.Selectable)({
24
22
  make: EventCollectorChannel_DynamoDbStream$ReventlessAws.make,
25
23
  connect: EventCollectorChannel_DynamoDbStream$ReventlessAws.connect
26
24
  })({
@@ -36,8 +34,8 @@ function Make(Api) {
36
34
  let make = (dcbEventLog, opts) => {
37
35
  let sv = InnerMake.make(dcbEventLog, opts);
38
36
  let queryDbOutputs = Component$ReventlessCore.outputs(sv).queryDb;
39
- let tableResource = queryDbOutputs.resources[0];
40
- StateViewSliceRuntime_Builder_Single$ReventlessAws.registerStateViewSlice(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Projection.moduleUrl), tableResource.name, queryDbOutputs.resources);
37
+ let queryDbTableName = QueryDbBackend$ReventlessAws.isPostgresFor(Spec.name) ? Pulumi.output(Spec.name) : queryDbOutputs.resources[0].name;
38
+ StateViewSliceRuntime_Builder_Single$ReventlessAws.registerStateViewSlice(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Projection.moduleUrl), queryDbTableName, queryDbOutputs.resources);
41
39
  return sv;
42
40
  };
43
41
  return {
@@ -65,4 +63,4 @@ export {
65
63
  EventCollectorRuntimeBuilder,
66
64
  Make,
67
65
  }
68
- /* Component-ReventlessCore Not a pure module */
66
+ /* @pulumi/pulumi Not a pure module */
@@ -15,8 +15,8 @@ module Make = (Api: {
15
15
  }) => {
16
16
  module Inner = ReventlessCore.StateViewSlice_Builder.Make(
17
17
  RuntimeEnvironment,
18
- QueryDbStorage_DynamoDbStream,
19
- QueryDbResolvers.AppSync,
18
+ QueryDbStorage.SelectableStream,
19
+ QueryDbResolvers.Selectable,
20
20
  EventCollectorChannel,
21
21
  EventCollectorRuntimeBuilder,
22
22
  Api,
@@ -36,12 +36,18 @@ module Make = (Api: {
36
36
  let make = (~dcbEventLog, ~opts=?): component => {
37
37
  let sv = InnerMake.make(~dcbEventLog, ~opts?)
38
38
  let queryDbOutputs = (sv->ReventlessCore.Component.outputs).queryDb
39
- let tableResource = queryDbOutputs.resources->Array.getUnsafe(0)
39
+ // On the Postgres backend there is no DynamoDB table (resources is
40
+ // empty) — the slice spec name is the stable `qdb_<name>` discriminator.
41
+ let queryDbTableName = if QueryDbBackend.isPostgresFor(Spec.name) {
42
+ Pulumi.Output.make(Spec.name)
43
+ } else {
44
+ (queryDbOutputs.resources->Array.getUnsafe(0)).name
45
+ }
40
46
  EventCollectorRuntimeBuilder.registerStateViewSlice(
41
47
  ~name=Spec.name,
42
48
  ~specModulePath=Util_Bundle.getModuleSpecifier(Spec.moduleUrl),
43
49
  ~projectionModulePath=Util_Bundle.getModuleSpecifier(Projection.moduleUrl),
44
- ~queryDbTableName=tableResource.name,
50
+ ~queryDbTableName,
45
51
  ~queryDbResources=queryDbOutputs.resources,
46
52
  )
47
53
  sv
@@ -1,11 +1,13 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Pulumi from "@pulumi/pulumi";
3
4
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
4
5
  import * as Util_Bundle$ReventlessAws from "../util/Util_Bundle.res.mjs";
6
+ import * as QueryDbBackend$ReventlessAws from "../adapter/QueryDb/QueryDbBackend.res.mjs";
7
+ import * as QueryDbStorage$ReventlessAws from "../adapter/QueryDb/QueryDbStorage.res.mjs";
8
+ import * as QueryDbResolvers$ReventlessAws from "../adapter/QueryDb/QueryDbResolvers.res.mjs";
5
9
  import * as StateViewSlice_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs";
6
- import * as QueryDbResolvers_AppSync$ReventlessAws from "../adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs";
7
10
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "../adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs";
8
- import * as QueryDbStorage_DynamoDbStream$ReventlessAws from "../adapter/QueryDb/QueryDbStorage_DynamoDbStream.res.mjs";
9
11
  import * as EventCollectorChannel_DynamoDbStream$ReventlessAws from "../adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs";
10
12
  import * as StateViewSliceRuntime_Builder_Single$ReventlessAws from "../adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs";
11
13
 
@@ -16,11 +18,7 @@ function Make(Api) {
16
18
  extractCorrelationId: RuntimeEnvironment_Lambda$ReventlessAws.extractCorrelationId,
17
19
  asEventHandler: prim => prim,
18
20
  asEffectHandler: prim => prim
19
- })({
20
- make: QueryDbStorage_DynamoDbStream$ReventlessAws.make
21
- })({
22
- make: QueryDbResolvers_AppSync$ReventlessAws.make
23
- })({
21
+ })(QueryDbStorage$ReventlessAws.SelectableStream)(QueryDbResolvers$ReventlessAws.Selectable)({
24
22
  make: EventCollectorChannel_DynamoDbStream$ReventlessAws.make,
25
23
  connect: EventCollectorChannel_DynamoDbStream$ReventlessAws.connect
26
24
  })({
@@ -36,8 +34,8 @@ function Make(Api) {
36
34
  let make = (dcbEventLog, opts) => {
37
35
  let sv = InnerMake.make(dcbEventLog, opts);
38
36
  let queryDbOutputs = Component$ReventlessCore.outputs(sv).queryDb;
39
- let tableResource = queryDbOutputs.resources[0];
40
- StateViewSliceRuntime_Builder_Single$ReventlessAws.registerStateViewSlice(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Projection.moduleUrl), tableResource.name, queryDbOutputs.resources);
37
+ let queryDbTableName = QueryDbBackend$ReventlessAws.isPostgresFor(Spec.name) ? Pulumi.output(Spec.name) : queryDbOutputs.resources[0].name;
38
+ StateViewSliceRuntime_Builder_Single$ReventlessAws.registerStateViewSlice(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Projection.moduleUrl), queryDbTableName, queryDbOutputs.resources);
41
39
  return sv;
42
40
  };
43
41
  return {
@@ -65,4 +63,4 @@ export {
65
63
  EventCollectorRuntimeBuilder,
66
64
  Make,
67
65
  }
68
- /* Component-ReventlessCore Not a pure module */
66
+ /* @pulumi/pulumi Not a pure module */
@@ -216,6 +216,18 @@ module Make = (
216
216
  }
217
217
  }
218
218
 
219
+ // Classic Postgres logs: `~eventTopics` is keyed by aggregate name, so each key
220
+ // attaches this plugin's EventCollector queue to exactly the aggregates whose
221
+ // event-log DynamoDB stream the collector would have subscribed on the DynamoDB
222
+ // path (admin's extension-point filtering included).
223
+ if EventLogBackend.isPostgres() {
224
+ eventTopics
225
+ ->Dict.keysToArray
226
+ ->Array.forEach(aggregateName =>
227
+ EventLogBackend.attachCollectorQueue(~aggregateName, ~url=queue.id, ~arn=queue.arn)
228
+ )
229
+ }
230
+
219
231
  // Pull the per-EventCollector context registered by Plugin_Helpers.connect
220
232
  // (plugins) or synthesise an admin one if none registered.
221
233
  let context: ReventlessCore.Plugin_Helpers.eventCollectorContext =
@@ -14,6 +14,7 @@ import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/p
14
14
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
15
15
  import * as ComponentType$ReventlessCore from "@reventlessdev/reventless-core/src/ComponentType.res.mjs";
16
16
  import * as EventCollector$ReventlessCore from "@reventlessdev/reventless-core/src/components/EventCollector/EventCollector.res.mjs";
17
+ import * as EventLogBackend$ReventlessAws from "../../adapter/EventLog/EventLogBackend.res.mjs";
17
18
  import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
18
19
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "../../adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs";
19
20
  import * as StateChangeSliceRuntime_Builder_Single$ReventlessAws from "../../adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs";
@@ -174,6 +175,9 @@ function Make(EventCollectorChannel) {
174
175
  DcbBackend$ReventlessAws.attachCollectorQueue(pluginName + "DcbEventLog", queue.id, queue.arn);
175
176
  }
176
177
  }
178
+ if (EventLogBackend$ReventlessAws.isPostgres()) {
179
+ Object.keys(eventTopics).forEach(aggregateName => EventLogBackend$ReventlessAws.attachCollectorQueue(aggregateName, queue.id, queue.arn));
180
+ }
177
181
  let ctx = Plugin_Helpers$ReventlessCore.eventCollectorContextRef.contents[name];
178
182
  let context = ctx !== undefined ? ctx : synthesizeAdminContext();
179
183
  let envVars = {};
@@ -0,0 +1,55 @@
1
+ // B3.0: the ReadModel/StateViewSlice record decoder must accept BOTH DynamoDB
2
+ // stream records (DynamoDB backend) and SQS records (Postgres backend — bodies
3
+ // injected by the PgChangeFeedRelay via the projection feed queue), in one batch.
4
+
5
+ open JestGlobals
6
+
7
+ let mkEvent = (records): PulumiAws.Lambda.CallbackFunction.event => Obj.magic({"Records": records})
8
+
9
+ let sqsRecord = body =>
10
+ {
11
+ "eventSource": "aws:sqs",
12
+ "eventSourceARN": "arn:aws:sqs:eu-west-1:1:AllReadModelsFeed",
13
+ "body": body,
14
+ }
15
+
16
+ let run = event => {
17
+ let seen: array<JSON.t> = []
18
+ let handler: ReventlessCore.EventCollector.jsonEventsHandler = stream =>
19
+ stream
20
+ ->Stream.runForEach(json => Effect.succeed(seen->Array.push(json)->ignore))
21
+ ->Effect.map(_ => ())
22
+ EventCollectorChannel_DynamoDbStream_Runtime.handleStreamEvent(handler, event, ())
23
+ ->Effect.runPromise
24
+ ->Promise.thenResolve(_ => seen)
25
+ }
26
+
27
+ describe("handleStreamEvent (ReadModel/SVS record decoder)", () => {
28
+ test("parses SQS feed-queue records into their JSON bodies", async () => {
29
+ let seen = await run(
30
+ mkEvent([
31
+ sqsRecord(`{"id":"agg-1","meta":{"service":"Product"},"event":{"type":"Added","data":{}}}`),
32
+ sqsRecord(`{"id":"agg-2","meta":{"service":"Product"},"event":{"type":"Added","data":{}}}`),
33
+ ]),
34
+ )
35
+ expect(seen->Array.length)->toBe(2)
36
+ let id0 =
37
+ seen
38
+ ->Array.getUnsafe(0)
39
+ ->JSON.Decode.object
40
+ ->Option.flatMap(o => o->Dict.get("id"))
41
+ ->Option.flatMap(JSON.Decode.string)
42
+ expect(id0)->toEqual(Some("agg-1"))
43
+ })
44
+
45
+ test("skips malformed SQS bodies and unknown sources without failing the batch", async () => {
46
+ let seen = await run(
47
+ mkEvent([
48
+ sqsRecord("not-json"),
49
+ {"eventSource": "aws:kinesis", "eventSourceARN": "arn:x", "body": "{}"},
50
+ sqsRecord(`{"ok":true}`),
51
+ ]),
52
+ )
53
+ expect(seen->Array.length)->toBe(1)
54
+ })
55
+ })
@@ -0,0 +1,78 @@
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 Effect from "effect/Effect";
6
+ import * as Stream from "effect/Stream";
7
+ import * as EventCollectorChannel_DynamoDbStream_Runtime$ReventlessAws from "../src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs";
8
+
9
+ function mkEvent(records) {
10
+ return {
11
+ Records: records
12
+ };
13
+ }
14
+
15
+ function sqsRecord(body) {
16
+ return {
17
+ eventSource: "aws:sqs",
18
+ eventSourceARN: "arn:aws:sqs:eu-west-1:1:AllReadModelsFeed",
19
+ body: body
20
+ };
21
+ }
22
+
23
+ function run(event) {
24
+ let seen = [];
25
+ let handler = stream => Effect.map(Stream.runForEach(stream, json => Effect.succeed((seen.push(json), undefined))), () => {});
26
+ return Effect.runPromise(EventCollectorChannel_DynamoDbStream_Runtime$ReventlessAws.handleStreamEvent(handler, event, undefined)).then(() => seen);
27
+ }
28
+
29
+ globalThis.describe("handleStreamEvent (ReadModel/SVS record decoder)", () => {
30
+ globalThis.test("parses SQS feed-queue records into their JSON bodies", async () => {
31
+ let seen = await run({
32
+ Records: [
33
+ {
34
+ eventSource: "aws:sqs",
35
+ eventSourceARN: "arn:aws:sqs:eu-west-1:1:AllReadModelsFeed",
36
+ body: `{"id":"agg-1","meta":{"service":"Product"},"event":{"type":"Added","data":{}}}`
37
+ },
38
+ {
39
+ eventSource: "aws:sqs",
40
+ eventSourceARN: "arn:aws:sqs:eu-west-1:1:AllReadModelsFeed",
41
+ body: `{"id":"agg-2","meta":{"service":"Product"},"event":{"type":"Added","data":{}}}`
42
+ }
43
+ ]
44
+ });
45
+ globalThis.expect(seen.length).toBe(2);
46
+ let id0 = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(seen[0]), o => o["id"]), Stdlib_JSON.Decode.string);
47
+ globalThis.expect(id0).toEqual("agg-1");
48
+ });
49
+ globalThis.test("skips malformed SQS bodies and unknown sources without failing the batch", async () => {
50
+ let seen = await run({
51
+ Records: [
52
+ {
53
+ eventSource: "aws:sqs",
54
+ eventSourceARN: "arn:aws:sqs:eu-west-1:1:AllReadModelsFeed",
55
+ body: "not-json"
56
+ },
57
+ {
58
+ eventSource: "aws:kinesis",
59
+ eventSourceARN: "arn:x",
60
+ body: "{}"
61
+ },
62
+ {
63
+ eventSource: "aws:sqs",
64
+ eventSourceARN: "arn:aws:sqs:eu-west-1:1:AllReadModelsFeed",
65
+ body: `{"ok":true}`
66
+ }
67
+ ]
68
+ });
69
+ globalThis.expect(seen.length).toBe(1);
70
+ });
71
+ });
72
+
73
+ export {
74
+ mkEvent,
75
+ sqsRecord,
76
+ run,
77
+ }
78
+ /* Not a pure module */
@@ -5,11 +5,13 @@
5
5
  // PG_URL=postgres://postgres:postgres@localhost:5432/postgres pnpm test
6
6
  //
7
7
  // Exercises the deployed propagation path end-to-end minus AWS: DCB events are
8
- // appended to a real `dcb_event` log, then `PgChangeFeedRelay_Runtime.relayWithPool`
9
- // drains the feed, transforms each event into the EventCollector `{id, meta, event}`
10
- // body, and hands the batch to an injected `sendBatch` (standing in for the SQS
8
+ // appended to a real `dcb_event` log (classic events to `event_log`), then
9
+ // `PgChangeFeedRelay_Runtime.relayWithPool` / `relayClassicWithPool` drains the
10
+ // feed, transforms each event into the EventCollector `{id, meta, event}` body,
11
+ // and hands the batch to an injected `sendBatch` (standing in for the SQS
11
12
  // SendMessage the entry point wires). Asserts the emitted bodies, the partition-tag
12
- // `id` derivation, and the checkpoint (a second drain sees nothing new).
13
+ // `id` derivation, and the checkpoints (a second drain sees nothing new; per-log
14
+ // subscribers stay isolated).
13
15
 
14
16
  open JestGlobals
15
17
  open ReventlessCore
@@ -110,6 +112,115 @@ switch processEnv->Dict.get("PG_URL") {
110
112
  expect(sink.contents->Array.length)->toBe(2)
111
113
  })
112
114
 
115
+ testPromise("classic: relays each appended event_log event as an {id, meta, event} body", async () => {
116
+ let (_n, ops, _s) =
117
+ ReventlessPostgres.EventLogStorage_Postgres.makeStorage(
118
+ ~pool,
119
+ ~name="ClassicRelayBasicEventLog",
120
+ ~opts=(),
121
+ )
122
+ // The flat on-disk item the classic append stores verbatim — same shape the
123
+ // DynamoDB path puts (id, position, event, data, decomposed meta).
124
+ let flatItem = (~id, ~seq, ~eventType, ~data) =>
125
+ [
126
+ ("id", JSON.Encode.string(id)),
127
+ ("position", JSON.Encode.string(seq->Int.toString->String.padStart(9, "0"))),
128
+ ("event", JSON.Encode.string(eventType)),
129
+ ("data", data),
130
+ ]
131
+ ->Array.concat(
132
+ ReventlessCore.Message.generateMeta(~service="relay-it")->ReventlessCore.Message.decomposeMeta,
133
+ )
134
+ ->Dict.fromArray
135
+ ->JSON.Encode.object
136
+ let _ = await ops.append(
137
+ 0,
138
+ "agg-1",
139
+ [
140
+ flatItem(~id="agg-1", ~seq=0, ~eventType="NameUpdated", ~data=jsonObj([("n", JSON.Encode.int(0))])),
141
+ flatItem(~id="agg-1", ~seq=1, ~eventType="NameUpdated", ~data=jsonObj([("n", JSON.Encode.int(1))])),
142
+ ],
143
+ )
144
+
145
+ let sink = ref([])
146
+ let processed = await PgChangeFeedRelay_Runtime.relayClassicWithPool(
147
+ ~pool,
148
+ ~logName="ClassicRelayBasicEventLog",
149
+ ~subscriber="relay-it:ClassicRelayBasicEventLog",
150
+ ~sendBatch=capturingSendBatch(sink),
151
+ )
152
+
153
+ expect(processed)->toBe(2)
154
+ expect(sink.contents->Array.length)->toBe(2)
155
+ let first = sink.contents->Array.getUnsafe(0)
156
+ expect(idOf(first))->toEqual(Some("agg-1"))
157
+ let obj = first->JSON.Decode.object->Option.getOrThrow
158
+ expect(
159
+ obj->Dict.get("event")->Option.getOrThrow->JSON.stringify->String.includes("NameUpdated"),
160
+ )->toBe(true)
161
+ let metaObj = obj->Dict.get("meta")->Option.flatMap(JSON.Decode.object)->Option.getOrThrow
162
+ expect(metaObj->Dict.get("service"))->toEqual(Some(JSON.Encode.string("relay-it")))
163
+
164
+ // Checkpointed: a second drain from the same subscriber sees nothing new.
165
+ let second = await PgChangeFeedRelay_Runtime.relayClassicWithPool(
166
+ ~pool,
167
+ ~logName="ClassicRelayBasicEventLog",
168
+ ~subscriber="relay-it:ClassicRelayBasicEventLog",
169
+ ~sendBatch=capturingSendBatch(sink),
170
+ )
171
+ expect(second)->toBe(0)
172
+ expect(sink.contents->Array.length)->toBe(2)
173
+ })
174
+
175
+ testPromise("classic: per-log subscribers keep checkpoints isolated across logs", async () => {
176
+ // event_log_subscription keys by subscriber alone. With a SHARED subscriber,
177
+ // draining log A after log B's events were appended would save a cursor past
178
+ // B's rows and silently skip them — the clobber the per-log subscriber fixes.
179
+ let mkOps = name => {
180
+ let (_n, ops, _s) =
181
+ ReventlessPostgres.EventLogStorage_Postgres.makeStorage(~pool, ~name, ~opts=())
182
+ ops
183
+ }
184
+ let opsB = mkOps("ClassicRelayIsoBEventLog")
185
+ let opsA = mkOps("ClassicRelayIsoAEventLog")
186
+ let meta = () =>
187
+ ReventlessCore.Message.generateMeta(~service="relay-it")->ReventlessCore.Message.decomposeMeta
188
+ let item = (id, eventType) =>
189
+ [
190
+ ("id", JSON.Encode.string(id)),
191
+ ("position", JSON.Encode.string("000000000")),
192
+ ("event", JSON.Encode.string(eventType)),
193
+ ("data", jsonObj([])),
194
+ ]
195
+ ->Array.concat(meta())
196
+ ->Dict.fromArray
197
+ ->JSON.Encode.object
198
+ // B's event commits FIRST (lower global_seq), then A's.
199
+ let _ = await opsB.append(0, "b-1", [item("b-1", "BHappened")])
200
+ let _ = await opsA.append(0, "a-1", [item("a-1", "AHappened")])
201
+
202
+ // Drain A first — its checkpoint lands PAST B's rows in global_seq order.
203
+ let sinkA = ref([])
204
+ let drainedA = await PgChangeFeedRelay_Runtime.relayClassicWithPool(
205
+ ~pool,
206
+ ~logName="ClassicRelayIsoAEventLog",
207
+ ~subscriber="relay-it:ClassicRelayIsoAEventLog",
208
+ ~sendBatch=capturingSendBatch(sinkA),
209
+ )
210
+ expect(drainedA)->toBe(1)
211
+
212
+ // B still sees its event because its subscriber has its own checkpoint.
213
+ let sinkB = ref([])
214
+ let drainedB = await PgChangeFeedRelay_Runtime.relayClassicWithPool(
215
+ ~pool,
216
+ ~logName="ClassicRelayIsoBEventLog",
217
+ ~subscriber="relay-it:ClassicRelayIsoBEventLog",
218
+ ~sendBatch=capturingSendBatch(sinkB),
219
+ )
220
+ expect(drainedB)->toBe(1)
221
+ expect(idOf(sinkB.contents->Array.getUnsafe(0)))->toEqual(Some("b-1"))
222
+ })
223
+
113
224
  testPromise("partitionTag pins the id to its tag on a multi-tag event", async () => {
114
225
  let (_n, ops, _s) =
115
226
  ReventlessPostgres.DcbEventLogStorage_Postgres.makeStorage(
@@ -8,6 +8,7 @@ import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Mess
8
8
  import * as PgDriver$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/PgDriver.res.mjs";
9
9
  import * as PgSchema$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/PgSchema.res.mjs";
10
10
  import * as PgChangeFeedRelay_Runtime$ReventlessAws from "../src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs";
11
+ import * as EventLogStorage_Postgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/EventLogStorage_Postgres.res.mjs";
11
12
  import * as DcbEventLogStorage_Postgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/DcbEventLogStorage_Postgres.res.mjs";
12
13
 
13
14
  let opts = {};
@@ -104,6 +105,89 @@ if (url !== undefined) {
104
105
  globalThis.expect(second).toBe(0);
105
106
  globalThis.expect(sink.contents.length).toBe(2);
106
107
  });
108
+ globalThis.test("classic: relays each appended event_log event as an {id, meta, event} body", async () => {
109
+ let match = EventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, "ClassicRelayBasicEventLog", undefined, undefined);
110
+ let flatItem = (id, seq, eventType, data) => Object.fromEntries([
111
+ [
112
+ "id",
113
+ id
114
+ ],
115
+ [
116
+ "position",
117
+ seq.toString().padStart(9, "0")
118
+ ],
119
+ [
120
+ "event",
121
+ eventType
122
+ ],
123
+ [
124
+ "data",
125
+ data
126
+ ]
127
+ ].concat(Message$ReventlessCore.decomposeMeta(Message$ReventlessCore.generateMeta("relay-it", undefined, undefined, undefined, undefined, undefined, undefined, undefined))));
128
+ await match[1].append(0, "agg-1", [
129
+ flatItem("agg-1", 0, "NameUpdated", Object.fromEntries([[
130
+ "n",
131
+ 0
132
+ ]])),
133
+ flatItem("agg-1", 1, "NameUpdated", Object.fromEntries([[
134
+ "n",
135
+ 1
136
+ ]]))
137
+ ]);
138
+ let sink = {
139
+ contents: []
140
+ };
141
+ let processed = await PgChangeFeedRelay_Runtime$ReventlessAws.relayClassicWithPool(pool, "ClassicRelayBasicEventLog", "relay-it:ClassicRelayBasicEventLog", capturingSendBatch(sink));
142
+ globalThis.expect(processed).toBe(2);
143
+ globalThis.expect(sink.contents.length).toBe(2);
144
+ let first = sink.contents[0];
145
+ globalThis.expect(idOf(first)).toEqual("agg-1");
146
+ let obj = Stdlib_Option.getOrThrow(Stdlib_JSON.Decode.object(first), undefined);
147
+ globalThis.expect(JSON.stringify(Stdlib_Option.getOrThrow(obj["event"], undefined)).includes("NameUpdated")).toBe(true);
148
+ let metaObj = Stdlib_Option.getOrThrow(Stdlib_Option.flatMap(obj["meta"], Stdlib_JSON.Decode.object), undefined);
149
+ globalThis.expect(metaObj["service"]).toEqual("relay-it");
150
+ let second = await PgChangeFeedRelay_Runtime$ReventlessAws.relayClassicWithPool(pool, "ClassicRelayBasicEventLog", "relay-it:ClassicRelayBasicEventLog", capturingSendBatch(sink));
151
+ globalThis.expect(second).toBe(0);
152
+ globalThis.expect(sink.contents.length).toBe(2);
153
+ });
154
+ globalThis.test("classic: per-log subscribers keep checkpoints isolated across logs", async () => {
155
+ let mkOps = name => EventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, name, undefined, undefined)[1];
156
+ let opsB = mkOps("ClassicRelayIsoBEventLog");
157
+ let opsA = mkOps("ClassicRelayIsoAEventLog");
158
+ let meta = () => Message$ReventlessCore.decomposeMeta(Message$ReventlessCore.generateMeta("relay-it", undefined, undefined, undefined, undefined, undefined, undefined, undefined));
159
+ let item = (id, eventType) => Object.fromEntries([
160
+ [
161
+ "id",
162
+ id
163
+ ],
164
+ [
165
+ "position",
166
+ "000000000"
167
+ ],
168
+ [
169
+ "event",
170
+ eventType
171
+ ],
172
+ [
173
+ "data",
174
+ Object.fromEntries([])
175
+ ]
176
+ ].concat(meta()));
177
+ await opsB.append(0, "b-1", [item("b-1", "BHappened")]);
178
+ await opsA.append(0, "a-1", [item("a-1", "AHappened")]);
179
+ let sinkA = {
180
+ contents: []
181
+ };
182
+ let drainedA = await PgChangeFeedRelay_Runtime$ReventlessAws.relayClassicWithPool(pool, "ClassicRelayIsoAEventLog", "relay-it:ClassicRelayIsoAEventLog", capturingSendBatch(sinkA));
183
+ globalThis.expect(drainedA).toBe(1);
184
+ let sinkB = {
185
+ contents: []
186
+ };
187
+ let drainedB = await PgChangeFeedRelay_Runtime$ReventlessAws.relayClassicWithPool(pool, "ClassicRelayIsoBEventLog", "relay-it:ClassicRelayIsoBEventLog", capturingSendBatch(sinkB));
188
+ globalThis.expect(drainedB).toBe(1);
189
+ globalThis.expect(idOf(sinkB.contents[0])).toEqual("b-1");
190
+ });
107
191
  globalThis.test("partitionTag pins the id to its tag on a multi-tag event", async () => {
108
192
  let match = DcbEventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, "relay-pt", [], {
109
193
  TAG: "Simple",