@reventlessdev/reventless-aws 3.0.0-alpha.186 → 3.0.0-alpha.187

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 CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.187 (2026-07-09)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **reventless-aws:** thread subIdConfig into deployed StateViewSlice projection runtime ([78ebf80](https://github.com/ReventlessDev/reventless-core/commit/78ebf8086406327a6d665d5dcf034630650c6803))
11
+
12
+
6
13
  # 3.0.0-alpha.186 (2026-07-08)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.186",
3
+ "version": "3.0.0-alpha.187",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -9,16 +9,16 @@
9
9
  "sury": "11.0.0-alpha.4",
10
10
  "uuid": "^13.0.0",
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
12
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
12
13
  "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
13
14
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.47",
14
15
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
15
- "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
16
- "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
17
16
  "@reventlessdev/reventless-core": "3.0.0-alpha.147",
17
+ "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
18
18
  "@reventlessdev/reventless-infra": "3.0.0-alpha.91",
19
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.11",
19
20
  "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
20
- "@reventlessdev/reventless-spec": "3.0.0-alpha.69",
21
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.11"
21
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.69"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "^12.3.0",
@@ -45,7 +45,7 @@ function groupBySource(records) {
45
45
  // `pgConnection`, when present, selects the Postgres QueryDb runtime for this
46
46
  // slice's view table (`queryDbTableName` is then the slice spec name, the shared
47
47
  // `qdb_<name>` discriminator). Absent → the DynamoDB path is byte-identical.
48
- function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName, pgConnection, stateTopicName) {
48
+ export function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName, pgConnection, stateTopicName) {
49
49
  let queryDbOps;
50
50
  if (pgConnection) {
51
51
  const indexes = (specModule.config && specModule.config.indexes) || [];
@@ -92,7 +92,12 @@ function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName,
92
92
  actions => Stream.fromIterable(actions)
93
93
  ),
94
94
  action => Effect.map(
95
- Effect.promise(() => handleAction(action, queryDbOps, undefined)),
95
+ // Thread the slice's `subIdConfig` (generated from `@subId`) so sub-id-dependent
96
+ // actions like `UpdateMultiState` can resolve the sort key. Dropping it here made
97
+ // every such action hit the `MissingSubIdConfig` guard and silently write nothing,
98
+ // while the test-harness callback path (which threads it) stayed green. `undefined`
99
+ // for slices without an `@subId` is correct — those never emit sub-id actions.
100
+ Effect.promise(() => handleAction(action, queryDbOps, specModule.subIdConfig)),
96
101
  _ => {}
97
102
  )
98
103
  );
@@ -0,0 +1,123 @@
1
+ // Integration regression guard for the StateViewSlice projection Lambda entry
2
+ // point (`StateViewSliceEntryPoint.mjs`), run against DynamoDB Local.
3
+ //
4
+ // Covers the 2026-07-09 incident: the deployed runtime built its projection
5
+ // loop with `handleAction(action, queryDbOps, undefined)` — hardcoding
6
+ // `subIdConfig` to `undefined` instead of threading the slice's
7
+ // `specModule.subIdConfig`. Every sub-id-dependent action (`UpdateMultiState`)
8
+ // then hit the `MissingSubIdConfig` guard and silently wrote nothing, while the
9
+ // in-memory GWT/callback path (which threads `subIdConfig`) stayed green. This
10
+ // is a deployed-runtime vs test-harness parity gap: a unit test on the callback
11
+ // path cannot catch it, so this test drives the *real* `.mjs` entry point that
12
+ // production uses.
13
+ //
14
+ // The fixture `SvsTestSlice` is an `@subId productId` view slice whose only
15
+ // projection path is `UpdateMultiState`. Before the fix both appends no-op and
16
+ // the table stays empty; after the fix each product is a distinct row.
17
+ //
18
+ // Boots via the same `pnpm run test:integration` Docker-gated suite as the DCB
19
+ // entry-point test; reuses `DcbIntegrationHarness` for table lifecycle commands.
20
+
21
+ open JestGlobals
22
+
23
+ module H = DcbIntegrationHarness
24
+
25
+ let s = JSON.Encode.string
26
+
27
+ // A view table for a `@subId` slice is a composite-key table: HASH `id`
28
+ // (primary id attribute injected by the QueryDb runtime) + RANGE `productId`
29
+ // (the slice's `subIdField`, carried as a plain attribute of each state row).
30
+ let createViewTable = async (tableName): Util_DynamoDb_Runtime.resolvedTable => {
31
+ let attrDef = name =>
32
+ Dict.fromArray([("AttributeName", s(name)), ("AttributeType", s("S"))])->JSON.Encode.object
33
+ let keyEl = (name, keyType) =>
34
+ Dict.fromArray([("AttributeName", s(name)), ("KeyType", s(keyType))])->JSON.Encode.object
35
+ let input =
36
+ Dict.fromArray([
37
+ ("TableName", s(tableName)),
38
+ ("AttributeDefinitions", [attrDef("id"), attrDef("productId")]->JSON.Encode.array),
39
+ ("KeySchema", [keyEl("id", "HASH"), keyEl("productId", "RANGE")]->JSON.Encode.array),
40
+ ("BillingMode", s("PAY_PER_REQUEST")),
41
+ ])->JSON.Encode.object
42
+ let _ = await H.send(H.createTableCommand(input))
43
+ {
44
+ Util_DynamoDb_Runtime.id: tableName,
45
+ name: tableName,
46
+ arn: `arn:aws:dynamodb:local:000000000000:table/${tableName}`,
47
+ hashKey: "id",
48
+ }
49
+ }
50
+
51
+ // Drives the real entry point: builds the handler via its exported
52
+ // `buildJsonEventsHandler` factory (DynamoDB path — `pgConnection`/`stateTopicName`
53
+ // undefined), feeds one encoded event through the effect Stream, and runs the
54
+ // resulting Effect through the request context the production handler supplies.
55
+ let runOneEvent: (string, JSON.t) => promise<unit> = %raw(`
56
+ async (tableName, eventJson) => {
57
+ const { buildJsonEventsHandler } = await import(
58
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/StateViewSliceEntryPoint.mjs"
59
+ );
60
+ const specModule = await import("./SvsTestSlice.res.mjs");
61
+ const projectionModule = await import("./SvsTestSlice_Projection.res.mjs");
62
+ const Effect = await import("effect/Effect");
63
+ const Stream = await import("effect/Stream");
64
+ const { tag: requestContextTag } = await import(
65
+ "@reventlessdev/reventless-core/src/RequestContext.res.mjs"
66
+ );
67
+
68
+ const handler = buildJsonEventsHandler(specModule, projectionModule, tableName, undefined, undefined);
69
+ const stream = Stream.fromIterable([eventJson]);
70
+ const effect = handler(stream)
71
+ .pipe(Effect.provideService(requestContextTag, { correlationId: "svs-test" }));
72
+ await Effect.runPromise(effect);
73
+ }
74
+ `)
75
+
76
+ // Encode the consumed event exactly as the runtime decodes it: the entry point
77
+ // calls `parseJsonOrThrow(eventJson, consumedEventSchema)`, so we produce the
78
+ // JSON via the same schema to guarantee a faithful round-trip.
79
+ let encodeEvent = (event): JSON.t =>
80
+ event->S.reverseConvertToJsonOrThrow(SvsTestSlice.consumedEventSchema)
81
+
82
+ describe("StateViewSliceEntryPoint integration", () => {
83
+ testAsync(
84
+ "UpdateMultiState on an @subId view slice persists a row per product (was silent no-op)",
85
+ async () => {
86
+ let table = await createViewTable("SvsIt_" ++ Date.now()->Float.toString)
87
+
88
+ // Two products added to the same cart → two distinct sub-id rows under the
89
+ // same primary id. The second append also reads back the first row, so a
90
+ // dropped `subIdConfig` (the bug) leaves the table empty at both steps.
91
+ await runOneEvent(
92
+ table.name,
93
+ encodeEvent(ItemAddedToCart({cartId: "cart-1", productId: "prod-a", qty: 2})),
94
+ )
95
+ await runOneEvent(
96
+ table.name,
97
+ encodeEvent(ItemAddedToCart({cartId: "cart-1", productId: "prod-b", qty: 5})),
98
+ )
99
+
100
+ let rows = switch await QueryDbStorage_DynamoDb_Runtime.load(table)("cart-1") {
101
+ | Ok(rows) => rows
102
+ | Error(_) => []
103
+ }
104
+
105
+ // Before the fix: 0 rows (both UpdateMultiState actions no-op with
106
+ // `MissingSubIdConfig`). After the fix: one row per product.
107
+ expect(rows->Array.length)->toBe(2)
108
+
109
+ let productIds =
110
+ rows
111
+ ->Array.filterMap(row =>
112
+ row
113
+ ->JSON.Decode.object
114
+ ->Option.flatMap(o => o->Dict.get("productId"))
115
+ ->Option.flatMap(JSON.Decode.string)
116
+ )
117
+ ->Array.toSorted(String.compare)
118
+ expect(productIds)->toEqual(["prod-a", "prod-b"])
119
+
120
+ await H.deleteTable(table)
121
+ },
122
+ )
123
+ })
@@ -0,0 +1,131 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
8
+ import * as ClientDynamodb from "@aws-sdk/client-dynamodb";
9
+ import * as SvsTestSlice$ReventlessAws from "./SvsTestSlice.res.mjs";
10
+ import * as DcbIntegrationHarness$ReventlessAws from "./DcbIntegrationHarness.res.mjs";
11
+ import * as QueryDbStorage_DynamoDb_Runtime$ReventlessAws from "../../src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs";
12
+
13
+ function s(prim) {
14
+ return prim;
15
+ }
16
+
17
+ async function createViewTable(tableName) {
18
+ let attrDef = name => Object.fromEntries([
19
+ [
20
+ "AttributeName",
21
+ name
22
+ ],
23
+ [
24
+ "AttributeType",
25
+ "S"
26
+ ]
27
+ ]);
28
+ let keyEl = (name, keyType) => Object.fromEntries([
29
+ [
30
+ "AttributeName",
31
+ name
32
+ ],
33
+ [
34
+ "KeyType",
35
+ keyType
36
+ ]
37
+ ]);
38
+ let input = Object.fromEntries([
39
+ [
40
+ "TableName",
41
+ tableName
42
+ ],
43
+ [
44
+ "AttributeDefinitions",
45
+ [
46
+ attrDef("id"),
47
+ attrDef("productId")
48
+ ]
49
+ ],
50
+ [
51
+ "KeySchema",
52
+ [
53
+ keyEl("id", "HASH"),
54
+ keyEl("productId", "RANGE")
55
+ ]
56
+ ],
57
+ [
58
+ "BillingMode",
59
+ "PAY_PER_REQUEST"
60
+ ]
61
+ ]);
62
+ await DcbIntegrationHarness$ReventlessAws.send(new ClientDynamodb.CreateTableCommand(input));
63
+ return {
64
+ id: tableName,
65
+ name: tableName,
66
+ arn: `arn:aws:dynamodb:local:000000000000:table/` + tableName,
67
+ hashKey: "id"
68
+ };
69
+ }
70
+
71
+ let runOneEvent = (async (tableName, eventJson) => {
72
+ const { buildJsonEventsHandler } = await import(
73
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/StateViewSliceEntryPoint.mjs"
74
+ );
75
+ const specModule = await import("./SvsTestSlice.res.mjs");
76
+ const projectionModule = await import("./SvsTestSlice_Projection.res.mjs");
77
+ const Effect = await import("effect/Effect");
78
+ const Stream = await import("effect/Stream");
79
+ const { tag: requestContextTag } = await import(
80
+ "@reventlessdev/reventless-core/src/RequestContext.res.mjs"
81
+ );
82
+
83
+ const handler = buildJsonEventsHandler(specModule, projectionModule, tableName, undefined, undefined);
84
+ const stream = Stream.fromIterable([eventJson]);
85
+ const effect = handler(stream)
86
+ .pipe(Effect.provideService(requestContextTag, { correlationId: "svs-test" }));
87
+ await Effect.runPromise(effect);
88
+ });
89
+
90
+ function encodeEvent(event) {
91
+ return S.reverseConvertToJsonOrThrow(event, SvsTestSlice$ReventlessAws.consumedEventSchema);
92
+ }
93
+
94
+ globalThis.describe("StateViewSliceEntryPoint integration", () => {
95
+ globalThis.test("UpdateMultiState on an @subId view slice persists a row per product (was silent no-op)", async () => {
96
+ let table = await createViewTable("SvsIt_" + Date.now().toString());
97
+ await runOneEvent(table.name, S.reverseConvertToJsonOrThrow({
98
+ TAG: "ItemAddedToCart",
99
+ cartId: "cart-1",
100
+ productId: "prod-a",
101
+ qty: 2
102
+ }, SvsTestSlice$ReventlessAws.consumedEventSchema));
103
+ await runOneEvent(table.name, S.reverseConvertToJsonOrThrow({
104
+ TAG: "ItemAddedToCart",
105
+ cartId: "cart-1",
106
+ productId: "prod-b",
107
+ qty: 5
108
+ }, SvsTestSlice$ReventlessAws.consumedEventSchema));
109
+ let rows = await QueryDbStorage_DynamoDb_Runtime$ReventlessAws.load(table)("cart-1");
110
+ let rows$1;
111
+ rows$1 = rows.TAG === "Ok" ? rows._0 : [];
112
+ globalThis.expect(rows$1.length).toBe(2);
113
+ let productIds = Stdlib_Array.filterMap(rows$1, row => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(row), o => o["productId"]), Stdlib_JSON.Decode.string)).toSorted(Primitive_string.compare);
114
+ globalThis.expect(productIds).toEqual([
115
+ "prod-a",
116
+ "prod-b"
117
+ ]);
118
+ return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
119
+ });
120
+ });
121
+
122
+ let H;
123
+
124
+ export {
125
+ H,
126
+ s,
127
+ createViewTable,
128
+ runOneEvent,
129
+ encodeEvent,
130
+ }
131
+ /* runOneEvent Not a pure module */
@@ -0,0 +1,32 @@
1
+ // Tiny StateViewSlice spec exercised by
2
+ // `StateViewSliceEntryPoint_IntegrationTest`. Standalone schema — does not
3
+ // depend on the example apps — so the fixture's shape can drift independently
4
+ // of any shipped slice.
5
+ //
6
+ // Hand-written explicit form rather than the `@@reventless.spec` PPX shorthand:
7
+ // the reventless-ppx is only wired into reventless-core's rescript.json, not
8
+ // reventless-aws's. So `consumedEventSchema`, `subIdConfig` etc. are declared
9
+ // directly.
10
+ //
11
+ // The slice models a per-cart, per-product line: primary id = `cartId`,
12
+ // sub-id = `productId`. Its projection uses `UpdateMultiState`, the action that
13
+ // hits the runtime's `subIdConfig` guard — the exact path the deployed entry
14
+ // point used to no-op by hardcoding `subIdConfig = undefined`.
15
+
16
+ @schema
17
+ type consumedEvent = ItemAddedToCart({cartId: string, productId: string, qty: int})
18
+
19
+ @schema
20
+ type state = {
21
+ cartId: string,
22
+ productId: string,
23
+ qty: int,
24
+ }
25
+
26
+ // `Some({subIdField, getSubId})` — the compiled per-slice spec that a real
27
+ // `@subId productId` view slice carries. `subIdField` is the DynamoDB range-key
28
+ // attribute; `getSubId` extracts it from a projected state row.
29
+ let subIdConfig: option<Reventless.ReadModel.subIdConfig<state>> = Some({
30
+ subIdField: "productId",
31
+ getSubId: state => state.productId,
32
+ })
@@ -0,0 +1,28 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+
5
+ let consumedEventSchema = S.schema(s => ({
6
+ TAG: "ItemAddedToCart",
7
+ cartId: s.m(S.string),
8
+ productId: s.m(S.string),
9
+ qty: s.m(S.int)
10
+ }));
11
+
12
+ let stateSchema = S.schema(s => ({
13
+ cartId: s.m(S.string),
14
+ productId: s.m(S.string),
15
+ qty: s.m(S.int)
16
+ }));
17
+
18
+ let subIdConfig = {
19
+ subIdField: "productId",
20
+ getSubId: state => state.productId
21
+ };
22
+
23
+ export {
24
+ consumedEventSchema,
25
+ stateSchema,
26
+ subIdConfig,
27
+ }
28
+ /* consumedEventSchema Not a pure module */
@@ -0,0 +1,24 @@
1
+ // Projection for `SvsTestSlice`. Hand-written `open` (no `@@reventless.projection`
2
+ // PPX in reventless-aws): `open Reventless.Projection` brings the action
3
+ // constructors (`UpdateMultiState`, …) into scope; `open SvsTestSlice` brings the
4
+ // consumed-event/state types.
5
+ //
6
+ // Every event upserts the cart's line for one product via `UpdateMultiState`,
7
+ // keyed by `productId` (the sub-id). This is the multi-state path that silently
8
+ // no-oped in the deployed runtime before `subIdConfig` was threaded.
9
+
10
+ open Reventless.Projection
11
+ open SvsTestSlice
12
+
13
+ let project = event =>
14
+ switch event {
15
+ | ItemAddedToCart({cartId, productId, qty}) => [
16
+ UpdateMultiState(
17
+ cartId,
18
+ states => {
19
+ let others = states->Array.filter(s => s.productId != productId)
20
+ others->Array.concat([{cartId, productId, qty}])
21
+ },
22
+ ),
23
+ ]
24
+ }
@@ -0,0 +1,25 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function project(event) {
5
+ let qty = event.qty;
6
+ let productId = event.productId;
7
+ let cartId = event.cartId;
8
+ return [{
9
+ TAG: "UpdateMultiState",
10
+ _0: cartId,
11
+ _1: states => {
12
+ let others = states.filter(s => s.productId !== productId);
13
+ return others.concat([{
14
+ cartId: cartId,
15
+ productId: productId,
16
+ qty: qty
17
+ }]);
18
+ }
19
+ }];
20
+ }
21
+
22
+ export {
23
+ project,
24
+ }
25
+ /* No side effect */