@reventlessdev/reventless-aws 3.0.0-alpha.277 → 3.0.0-alpha.279

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.
@@ -42,6 +42,47 @@
42
42
  //
43
43
  // Usage in Platform.res (once, after admin + plugins have wired):
44
44
  // StateTopic_AppSync.finish(~eventsApi, ~opts)
45
+ //
46
+ // ── Tables that are not read models ──────────────────────────────────────────
47
+ //
48
+ // Nothing below the registration is read-model-specific: the relay routes per
49
+ // record through STATE_TOPIC_MAP and derives the entity channel from the record's
50
+ // own `Keys`. `makeForTable` is the front door for a component that provisions
51
+ // its own DynamoDB table and wants the same descriptors — state that is a PRIMARY
52
+ // record rather than a projection (a ledger accumulated by `ADD` increments,
53
+ // say) is not a read model and must not be declared one, but its rows are exactly
54
+ // the shape the relay expects.
55
+ //
56
+ // StateTopic_AppSync.makeForTable(
57
+ // ~tableName=myTable.name,
58
+ // ~streamArn=myTable.streamArn,
59
+ // ~partitionKeyName=myTable.hashKey,
60
+ // ~topicName="Platform-UsageLedger",
61
+ // ~eventsApi,
62
+ // ~opts,
63
+ // )
64
+ //
65
+ // Three things a caller has to get right, none of which the type checker can:
66
+ //
67
+ // 1. TOPIC NAMING IS THE CALLER'S JOB, AND A MISMATCH IS SILENT. A read model
68
+ // derives its topic from the generated plural list field, so publisher and
69
+ // subscriber agree by construction. A self-provisioned table has no such
70
+ // field — the topic passed here is simply believed. Publishing to a channel
71
+ // nobody listens on succeeds, so a typo shows up as "live updates never
72
+ // arrive", not as an error. The topic belongs to whoever READS the data:
73
+ // change the reader and the registration together, in one commit.
74
+ //
75
+ // 2. THE TABLE MUST BE KEYED ON `id`. Checked at deploy time — see
76
+ // `StateTopic_AppSync_Helpers.checkPartitionKeyName` for why a build error
77
+ // beats the alternative. A sort key is fine (entity key becomes
78
+ // `{id}-{sortValue}`, which the reader must also know).
79
+ //
80
+ // 3. REGISTRATION IS OPT-IN, PER TABLE, AND NOT FREE UNDER LOAD. Never register
81
+ // a table just because it happens to have a stream. A stream is silent while
82
+ // the table is idle — which is why this beats polling for the common quiet
83
+ // case — but a write burst costs one relay invocation per record batch, and
84
+ // avoiding exactly that per-write work is often why such a table exists in
85
+ // the first place. Measure before enabling it on a write-hot table.
45
86
 
46
87
  open PulumiAws
47
88
 
@@ -84,12 +125,40 @@ let registry: dict<array<streamEntry>> = Dict.make()
84
125
 
85
126
  // ── Registration ──────────────────────────────────────────────────────────────
86
127
 
128
+ /** Register any stream-enabled DynamoDB table with the relay. Read-model and
129
+ self-provisioned tables land in the SAME registry, so they share one `finish`,
130
+ one Lambda, one IAM statement and one STATE_TOPIC_MAP — see the module header
131
+ for the three things a self-provisioned caller has to get right. */
132
+ let makeForTable = (
133
+ ~tableName: Pulumi.Output.t<string>,
134
+ ~streamArn: Pulumi.Output.t<string>,
135
+ ~partitionKeyName: Pulumi.Output.t<string>,
136
+ ~topicName: string,
137
+ ~eventsApi: AppSync_EventsApi.t,
138
+ ~opts as _: Pulumi.CustomResourceOptions.t,
139
+ ) => {
140
+ // The check rides ON the tableName that gets registered rather than sitting in
141
+ // an apply of its own, so it cannot rot into dead code: STATE_TOPIC_MAP is built
142
+ // from this Output, so the apply always runs and a violation fails the deploy.
143
+ let checkedTableName =
144
+ (tableName, partitionKeyName)
145
+ ->Pulumi.Output.all2
146
+ ->Pulumi.Output.apply(((tableName, partitionKeyName)) => {
147
+ StateTopic_AppSync_Helpers.checkPartitionKeyName(~tableName, ~partitionKeyName)
148
+ tableName
149
+ })
150
+
151
+ let key = eventsApi.name
152
+ let entries = registry->Dict.get(key)->Option.getOr([])
153
+ registry->Dict.set(key, entries->Array.concat([{tableName: checkedTableName, streamArn, topicName}]))
154
+ }
155
+
87
156
  let make = (
88
157
  ~readModelName: string,
89
158
  ~topicName: string,
90
159
  ~allQueryDbs: ReventlessCore.QueryDb.allOutputs,
91
160
  ~eventsApi: AppSync_EventsApi.t,
92
- ~opts as _: Pulumi.CustomResourceOptions.t,
161
+ ~opts: Pulumi.CustomResourceOptions.t,
93
162
  ) => {
94
163
  // Look up the QueryDb resources by ReadModel Spec.name, then find the
95
164
  // DynamoDB stream resource within them. Requires QueryDbStorage_DynamoDbStream.
@@ -98,12 +167,18 @@ let make = (
98
167
  ->ReventlessCore.Util.ReadModel.queryDbStorageResources(readModelName)
99
168
  ->Util_DynamoDbStream.findResource
100
169
 
101
- let streamArn = Util_DynamoDbStream.streamArnFromDynamoDbTableResource(streamResource)
102
- let tableName = streamResource.name
103
-
104
- let key = eventsApi.name
105
- let entries = registry->Dict.get(key)->Option.getOr([])
106
- registry->Dict.set(key, entries->Array.concat([{tableName, streamArn, topicName}]))
170
+ makeForTable(
171
+ ~tableName=streamResource.name,
172
+ ~streamArn=Util_DynamoDbStream.streamArnFromDynamoDbTableResource(streamResource),
173
+ // QueryDbStorage_DynamoDb* keys every table it provisions on `id`, so here the
174
+ // check restates an invariant the framework already holds. It exists for the
175
+ // tables the framework did NOT create, which reach the registry via
176
+ // `makeForTable` and can be keyed anything.
177
+ ~partitionKeyName=StateTopic_AppSync_Helpers.entityKeyPartitionAttribute->Pulumi.Output.make,
178
+ ~topicName,
179
+ ~eventsApi,
180
+ ~opts,
181
+ )
107
182
  }
108
183
 
109
184
  // ── Finalize: build the shared Lambda + IAM + ESMs ────────────────────────────
@@ -14,22 +14,33 @@ import * as Util_ReadModel$ReventlessCore from "@reventlessdev/reventless-core/s
14
14
  import * as AppSync_EventsApi$ReventlessAws from "../Api/AppSync_EventsApi.res.mjs";
15
15
  import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
16
16
  import * as Util_DynamoDbStream$ReventlessAws from "../../util/Util_DynamoDbStream.res.mjs";
17
+ import * as StateTopic_AppSync_Helpers$ReventlessAws from "./StateTopic_AppSync_Helpers.res.mjs";
17
18
 
18
19
  let registry = {};
19
20
 
20
- function make(readModelName, topicName, allQueryDbs, eventsApi, param) {
21
- let streamResource = Util_DynamoDbStream$ReventlessAws.findResource(Util_ReadModel$ReventlessCore.queryDbStorageResources(allQueryDbs, readModelName));
22
- let streamArn = Util_DynamoDbStream$ReventlessAws.streamArnFromDynamoDbTableResource(streamResource);
23
- let tableName = streamResource.name;
21
+ function makeForTable(tableName, streamArn, partitionKeyName, topicName, eventsApi, param) {
22
+ let checkedTableName = Pulumi.all([
23
+ tableName,
24
+ partitionKeyName
25
+ ]).apply(param => {
26
+ let tableName = param[0];
27
+ StateTopic_AppSync_Helpers$ReventlessAws.checkPartitionKeyName(tableName, param[1]);
28
+ return tableName;
29
+ });
24
30
  let key = eventsApi.name;
25
31
  let entries = Stdlib_Option.getOr(registry[key], []);
26
32
  registry[key] = entries.concat([{
27
- tableName: tableName,
33
+ tableName: checkedTableName,
28
34
  streamArn: streamArn,
29
35
  topicName: topicName
30
36
  }]);
31
37
  }
32
38
 
39
+ function make(readModelName, topicName, allQueryDbs, eventsApi, opts) {
40
+ let streamResource = Util_DynamoDbStream$ReventlessAws.findResource(Util_ReadModel$ReventlessCore.queryDbStorageResources(allQueryDbs, readModelName));
41
+ makeForTable(streamResource.name, Util_DynamoDbStream$ReventlessAws.streamArnFromDynamoDbTableResource(streamResource), Pulumi.output(StateTopic_AppSync_Helpers$ReventlessAws.entityKeyPartitionAttribute), topicName, eventsApi, opts);
42
+ }
43
+
33
44
  function finish(eventsApi, opts) {
34
45
  let key = eventsApi.name;
35
46
  let entries = registry[key];
@@ -144,6 +155,7 @@ function finish(eventsApi, opts) {
144
155
 
145
156
  export {
146
157
  registry,
158
+ makeForTable,
147
159
  make,
148
160
  finish,
149
161
  }
@@ -0,0 +1,36 @@
1
+ // Pulumi-free helpers for the state-topic relay's deploy-time registration.
2
+ //
3
+ // Split out of `StateTopic_AppSync.res` so the key-schema check can be driven
4
+ // headlessly — the registration module itself pulls in @pulumi/aws.
5
+
6
+ /** The attribute name `StateTopic_AppSync_Ops.entityKeyFromRecord` looks for when
7
+ it builds a change descriptor's entity key from the stream record's `Keys`.
8
+ Every framework-provisioned QueryDb table uses it; a self-provisioned table
9
+ registered through `makeForTable` must too. */
10
+ let entityKeyPartitionAttribute = "id"
11
+
12
+ /** Reject a table whose partition attribute is not `id`.
13
+
14
+ The relay does not fail on such a table — it publishes descriptors carrying a
15
+ joined-and-sorted fallback key, which surfaces much later as clients
16
+ refetching the wrong entity. Turning that into a build error is the whole
17
+ point: the message names the table and its actual key so the fix is obvious
18
+ at the registration site.
19
+
20
+ At most one sort key needs no check — DynamoDB tables cannot have more than
21
+ one, so the relay's `{id}-{sortValue}` composition is total by construction.
22
+
23
+ Naming the table costs one thing worth knowing: the caller resolves the table
24
+ name to build the message, so on the very first deploy of a NEW table — where
25
+ that name is still unknown at preview — the failure lands during the update
26
+ rather than the preview. It fails the deploy either way. */
27
+ let checkPartitionKeyName = (~tableName: string, ~partitionKeyName: string): unit =>
28
+ if partitionKeyName != entityKeyPartitionAttribute {
29
+ JsError.throwWithMessage(
30
+ `StateTopic: table "${tableName}" is keyed on "${partitionKeyName}", but the ` ++
31
+ `state-topic relay derives a change descriptor's entity key from an attribute ` ++
32
+ `named "${entityKeyPartitionAttribute}". Registering it would publish ` ++
33
+ `descriptors whose id does not match the row. Rename the partition key to ` ++
34
+ `"${entityKeyPartitionAttribute}", or do not register this table.`,
35
+ )
36
+ }
@@ -0,0 +1,17 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
4
+
5
+ let entityKeyPartitionAttribute = "id";
6
+
7
+ function checkPartitionKeyName(tableName, partitionKeyName) {
8
+ if (partitionKeyName !== entityKeyPartitionAttribute) {
9
+ return Stdlib_JsError.throwWithMessage(`StateTopic: table "` + tableName + `" is keyed on "` + partitionKeyName + `", but the state-topic relay derives a change descriptor's entity key from an attribute ` + (`named "` + entityKeyPartitionAttribute + `". Registering it would publish `) + `descriptors whose id does not match the row. Rename the partition key to ` + (`"` + entityKeyPartitionAttribute + `", or do not register this table.`));
10
+ }
11
+ }
12
+
13
+ export {
14
+ entityKeyPartitionAttribute,
15
+ checkPartitionKeyName,
16
+ }
17
+ /* No side effect */
@@ -0,0 +1,143 @@
1
+ open JestGlobals
2
+
3
+ // Guards the preview-unknown decoupling in `EventCollectorChannel_Helpers.connectLambda`.
4
+ //
5
+ // The collector's role policy and its event-source mappings used to be created
6
+ // inside ONE `Pulumi.Output.all3` apply over three unrelated input sets. Pulumi
7
+ // skips an apply whose inputs are unknown, and a resource the program never
8
+ // registers reads as a DELETE — so a single unknown input removed all of them
9
+ // from the preview, including mappings that never read it. That is how switching
10
+ // a plugin's slices to the stream builder deleted the plugin's event-log mapping:
11
+ // the new view tables' computed `streamArn` is unknown in the preview that
12
+ // enables it, and the view tables shared the apply with everything else.
13
+ //
14
+ // These tests drive `connectLambda` under Pulumi's mock runtime in preview mode
15
+ // with a genuinely unknown view-table resource, and assert the two resources that
16
+ // do not depend on it are still registered.
17
+
18
+ // ── Pulumi mock runtime ──────────────────────────────────────────────────────
19
+
20
+ type mockArgs = {@as("type") type_: string, name: string}
21
+ type mockResult = {id: string, state: JSON.t}
22
+
23
+ @module("@pulumi/pulumi") @scope("runtime")
24
+ external setMocks: (
25
+ {"newResource": mockArgs => mockResult, "call": mockArgs => JSON.t},
26
+ string,
27
+ string,
28
+ bool,
29
+ ) => unit = "setMocks"
30
+
31
+ // The engine's unknown-during-preview Output: resolved, but flagged not-known, so
32
+ // `.apply` never fires while the value is still accepted as a resource INPUT.
33
+ // Constructed the way the engine does rather than faked with a pending promise —
34
+ // a promise that never settles would also stall resource registration, which is
35
+ // the very difference under test.
36
+ @module("@pulumi/pulumi") @new
37
+ external makeOutput: (
38
+ Set.t<unit>,
39
+ promise<'a>,
40
+ promise<bool>,
41
+ promise<bool>,
42
+ promise<Set.t<unit>>,
43
+ ) => Pulumi.Output.t<'a> = "Output"
44
+
45
+ let unknown = (): Pulumi.Output.t<'a> =>
46
+ makeOutput(
47
+ Set.make(),
48
+ Promise.resolve(%raw(`undefined`)),
49
+ Promise.resolve(false),
50
+ Promise.resolve(false),
51
+ Promise.resolve(Set.make()),
52
+ )
53
+
54
+ let registered: array<(string, string)> = []
55
+
56
+ beforeAll(() =>
57
+ setMocks(
58
+ {
59
+ "newResource": args => {
60
+ registered->Array.push((args.type_, args.name))
61
+ {id: args.name ++ "_id", state: JSON.Encode.object(Dict.make())}
62
+ },
63
+ "call": _ => JSON.Encode.object(Dict.make()),
64
+ },
65
+ "reventless-test",
66
+ "test",
67
+ true,
68
+ )
69
+ )
70
+
71
+ // Pulumi registers resources asynchronously; give the runtime a few ticks.
72
+ let settle = async () => await Promise.make((resolve, _) => {
73
+ let _ = setTimeout(() => resolve(), 300)
74
+ })
75
+
76
+ let wasRegistered = (type_, name) =>
77
+ registered->Array.some(((t, n)) => t == type_ && n == name)
78
+
79
+ // ── Fixtures ─────────────────────────────────────────────────────────────────
80
+
81
+ // A view table whose fields never become known — what a QueryDb stream resource
82
+ // looks like in the preview that first enables its stream.
83
+ let unknownViewTable = ReventlessInfra.Adapter.make(
84
+ ~name=unknown(),
85
+ ~id=unknown(),
86
+ ~urn=unknown(),
87
+ ~service=unknown(),
88
+ ~resourceInfo=unknown(),
89
+ )
90
+
91
+ // A known event-log stream — the ESM source. Unaffected by the slice switch.
92
+ let knownEventLogStream = ReventlessInfra.Adapter.make(
93
+ ~name="CatalogDcbEventLog-abc123"->Pulumi.Output.make,
94
+ ~id="stream-id"->Pulumi.Output.make,
95
+ ~urn="arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000"->Pulumi.Output.make,
96
+ ~service=AWS.DynamoDbStream.service->Pulumi.Output.make,
97
+ ~resourceInfo=ReventlessInfra.Adapter.StreamSource({
98
+ sourceUrn: "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000",
99
+ })->Pulumi.Output.make,
100
+ )
101
+
102
+ let lambdaRole: PulumiAws.IAM.Role.t = {
103
+ arn: "arn:aws:iam::123456789012:role/AllStateViewSlicesRole"->Pulumi.Output.make,
104
+ name: "AllStateViewSlicesRole"->Pulumi.Output.make,
105
+ id: "AllStateViewSlicesRole"->Pulumi.Output.make,
106
+ }
107
+
108
+ let lambda: Pulumi.Output.t<PulumiAws.Lambda.Function.t> = Pulumi.Output.make({
109
+ PulumiAws.Lambda.Function.arn: "arn:aws:lambda:eu-west-1:123456789012:function:AllStateViewSlices"->Pulumi.Output.make,
110
+ id: "AllStateViewSlices"->Pulumi.Output.make,
111
+ name: "AllStateViewSlices"->Pulumi.Output.make,
112
+ invokeArn: "arn:aws:apigateway:invoke"->Pulumi.Output.make,
113
+ })
114
+
115
+ // ── Tests ────────────────────────────────────────────────────────────────────
116
+
117
+ describe("EventCollectorChannel_Helpers.connectLambda under an unknown view table", () => {
118
+ let name = "AllStateViewSlices"
119
+
120
+ beforeAllAsync(async () => {
121
+ let _ = EventCollectorChannel_Helpers.connectLambda(
122
+ lambda,
123
+ name,
124
+ lambdaRole,
125
+ [],
126
+ Dict.fromArray([("DcbEventLog", {ReventlessInfra.EventTopic.resources: [knownEventLogStream]})]),
127
+ [unknownViewTable],
128
+ {},
129
+ )
130
+ await settle()
131
+ })
132
+
133
+ test("still registers the collector's role policy", async () => {
134
+ // The policy DOCUMENT genuinely depends on the unknown table, but the policy
135
+ // RESOURCE must not: an unknown input previews as "value unknown", whereas an
136
+ // unregistered resource previews as a delete.
137
+ expect(wasRegistered("aws:iam/rolePolicy:RolePolicy", name))->toBe(true)
138
+ })
139
+
140
+ test("still registers the event-log mapping, which never reads a view table", async () => {
141
+ expect(wasRegistered("aws:lambda/eventSourceMapping:EventSourceMapping", "CatalogDcbEventLog2" ++ name))->toBe(true)
142
+ })
143
+ })
@@ -0,0 +1,101 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Pulumi from "@pulumi/pulumi";
4
+ import * as AWS$ReventlessAws from "../src/adapter/AWS.res.mjs";
5
+ import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
6
+ import * as EventCollectorChannel_Helpers$ReventlessAws from "../src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs";
7
+
8
+ function unknown() {
9
+ return new Pulumi.Output(new Set(), Promise.resolve(undefined), Promise.resolve(false), Promise.resolve(false), Promise.resolve(new Set()));
10
+ }
11
+
12
+ let registered = [];
13
+
14
+ globalThis.beforeAll(() => {
15
+ Pulumi.runtime.setMocks({
16
+ newResource: args => {
17
+ registered.push([
18
+ args.type,
19
+ args.name
20
+ ]);
21
+ return {
22
+ id: args.name + "_id",
23
+ state: {}
24
+ };
25
+ },
26
+ call: param => ({})
27
+ }, "reventless-test", "test", true);
28
+ });
29
+
30
+ async function settle() {
31
+ return await new Promise((resolve, param) => {
32
+ setTimeout(() => resolve(), 300);
33
+ });
34
+ }
35
+
36
+ function wasRegistered(type_, name) {
37
+ return registered.some(param => {
38
+ if (param[0] === type_) {
39
+ return param[1] === name;
40
+ } else {
41
+ return false;
42
+ }
43
+ });
44
+ }
45
+
46
+ let unknownViewTable = Adapter$ReventlessInfra.make(unknown(), unknown(), unknown(), unknown(), unknown(), undefined, undefined, undefined, undefined, undefined);
47
+
48
+ let knownEventLogStream = Adapter$ReventlessInfra.make(Pulumi.output("CatalogDcbEventLog-abc123"), Pulumi.output("stream-id"), Pulumi.output("arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000"), Pulumi.output(AWS$ReventlessAws.DynamoDbStream.service), Pulumi.output({
49
+ TAG: "StreamSource",
50
+ sourceUrn: "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000"
51
+ }), undefined, undefined, undefined, undefined, undefined);
52
+
53
+ let lambdaRole_arn = Pulumi.output("arn:aws:iam::123456789012:role/AllStateViewSlicesRole");
54
+
55
+ let lambdaRole_name = Pulumi.output("AllStateViewSlicesRole");
56
+
57
+ let lambdaRole_id = Pulumi.output("AllStateViewSlicesRole");
58
+
59
+ let lambdaRole = {
60
+ arn: lambdaRole_arn,
61
+ name: lambdaRole_name,
62
+ id: lambdaRole_id
63
+ };
64
+
65
+ let lambda = Pulumi.output({
66
+ arn: Pulumi.output("arn:aws:lambda:eu-west-1:123456789012:function:AllStateViewSlices"),
67
+ id: Pulumi.output("AllStateViewSlices"),
68
+ name: Pulumi.output("AllStateViewSlices"),
69
+ invokeArn: Pulumi.output("arn:aws:apigateway:invoke")
70
+ });
71
+
72
+ globalThis.describe("EventCollectorChannel_Helpers.connectLambda under an unknown view table", () => {
73
+ let name = "AllStateViewSlices";
74
+ globalThis.beforeAll(async () => {
75
+ EventCollectorChannel_Helpers$ReventlessAws.connectLambda(lambda, name, lambdaRole, [], Object.fromEntries([[
76
+ "DcbEventLog",
77
+ {
78
+ resources: [knownEventLogStream]
79
+ }
80
+ ]]), [unknownViewTable], {});
81
+ return await settle();
82
+ });
83
+ globalThis.test("still registers the collector's role policy", async () => {
84
+ globalThis.expect(wasRegistered("aws:iam/rolePolicy:RolePolicy", name)).toBe(true);
85
+ });
86
+ globalThis.test("still registers the event-log mapping, which never reads a view table", async () => {
87
+ globalThis.expect(wasRegistered("aws:lambda/eventSourceMapping:EventSourceMapping", "CatalogDcbEventLog2" + name)).toBe(true);
88
+ });
89
+ });
90
+
91
+ export {
92
+ unknown,
93
+ registered,
94
+ settle,
95
+ wasRegistered,
96
+ unknownViewTable,
97
+ knownEventLogStream,
98
+ lambdaRole,
99
+ lambda,
100
+ }
101
+ /* Not a pure module */
@@ -0,0 +1,87 @@
1
+ // The read-path interceptor runtime: its contract, and the cold-start seam it
2
+ // depends on for that contract to mean anything.
3
+ //
4
+ // The hook this handler consults is a module-level `ref`, and in a deployed
5
+ // runtime only a `RuntimeExtension`'s `onColdStart` ever fills it. This runtime
6
+ // is not built by a compiled entry shell, so it awaits `runtimeExtensionsReady`
7
+ // itself — and the failure mode when it does not is silent by construction: the
8
+ // hook reads `None`, every read is allowed, and interception costs a full Lambda
9
+ // invocation per read while observing nothing. Nothing errors, so only a test
10
+ // says so.
11
+
12
+ open JestGlobals
13
+ open ReventlessCore
14
+
15
+ // Both names must resolve to the SAME promise. HandlerFactoryHelpers re-exports
16
+ // this binding rather than defining its own precisely so the seam fires once per
17
+ // process; a second definition would fire every registered extension's
18
+ // `onColdStart` twice in any runtime that reached both modules.
19
+ @module("../src/adapter/Runtime/RuntimeExtensionsReady.mjs")
20
+ external readyFromOwnModule: promise<unit> = "runtimeExtensionsReady"
21
+
22
+ @module("../src/adapter/Runtime/HandlerFactoryHelpers.mjs")
23
+ external readyFromHelpers: promise<unit> = "runtimeExtensionsReady"
24
+
25
+ let identity: Reventless.Identity.t = {
26
+ userId: "anonymous",
27
+ username: "anonymous",
28
+ groups: [],
29
+ provider: InMemory,
30
+ }
31
+
32
+ let read = (~readModelName="Product"): QueryInterceptor_Lambda.payload => {
33
+ readModelName,
34
+ arguments: JSON.Null,
35
+ identity,
36
+ }
37
+
38
+ // The handler ignores it; Lambda supplies the real one.
39
+ let context: PulumiAws.Lambda.context = %raw(`{}`)
40
+
41
+ describe("cold-start seam", () => {
42
+ test("one seam, one promise — the helpers re-export is not a second copy", async () => {
43
+ expect(readyFromHelpers === readyFromOwnModule)->toBe(true)
44
+ })
45
+ })
46
+
47
+ describe("interceptor handler", () => {
48
+ beforeEach(() => QueryDb_Callback.clearQueryInterceptor())
49
+
50
+ testPromise("no registered hook passes the read through", async () => {
51
+ let allowed = await QueryInterceptor_Lambda.handler(read(), context)
52
+ expect(allowed)->toBe(true)
53
+ })
54
+
55
+ testPromise("an allowing hook is consulted and the read proceeds", async () => {
56
+ let seen = ref([])
57
+ QueryDb_Callback.registerQueryInterceptor(async (
58
+ ~identity as _,
59
+ ~readModelName,
60
+ ~args as _,
61
+ ) => {
62
+ seen := seen.contents->Array.concat([readModelName])
63
+ QueryDb_Callback.Allow
64
+ })
65
+ let allowed = await QueryInterceptor_Lambda.handler(read(~readModelName="Order"), context)
66
+ expect((allowed, seen.contents))->toEqual((true, ["Order"]))
67
+ })
68
+
69
+ testPromise("a denying hook fails the read with the hook's own message", async () => {
70
+ // The refusal has to surface as a thrown error: the pipeline's response
71
+ // function turns `ctx.error` into a GraphQL field error, and a returned
72
+ // `false` would read as a successful read of nothing.
73
+ QueryDb_Callback.registerQueryInterceptor(async (
74
+ ~identity as _,
75
+ ~readModelName as _,
76
+ ~args as _,
77
+ ) => QueryDb_Callback.Deny("over the allowance"))
78
+ let message = try {
79
+ let _ = await QueryInterceptor_Lambda.handler(read(), context)
80
+ "did not throw"
81
+ } catch {
82
+ | JsExn(e) => e->JsExn.message->Option.getOr("no message")
83
+ | _ => "not a JS error"
84
+ }
85
+ expect(message)->Expect.toBe("over the allowance")
86
+ })
87
+ })
@@ -0,0 +1,103 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
6
+ import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
7
+ import * as QueryInterceptor_Lambda$ReventlessAws from "../src/adapter/QueryDb/QueryInterceptor_Lambda.res.mjs";
8
+ import * as HandlerFactoryHelpersMjs from "../src/adapter/Runtime/HandlerFactoryHelpers.mjs";
9
+ import * as RuntimeExtensionsReadyMjs from "../src/adapter/Runtime/RuntimeExtensionsReady.mjs";
10
+
11
+ let readyFromOwnModule = RuntimeExtensionsReadyMjs.runtimeExtensionsReady;
12
+
13
+ let readyFromHelpers = HandlerFactoryHelpersMjs.runtimeExtensionsReady;
14
+
15
+ let identity_groups = [];
16
+
17
+ let identity = {
18
+ userId: "anonymous",
19
+ username: "anonymous",
20
+ groups: identity_groups,
21
+ provider: "InMemory"
22
+ };
23
+
24
+ function read(readModelNameOpt) {
25
+ let readModelName = readModelNameOpt !== undefined ? readModelNameOpt : "Product";
26
+ return {
27
+ readModelName: readModelName,
28
+ arguments: null,
29
+ identity: identity
30
+ };
31
+ }
32
+
33
+ let context = {};
34
+
35
+ globalThis.describe("cold-start seam", () => {
36
+ globalThis.test("one seam, one promise — the helpers re-export is not a second copy", async () => {
37
+ globalThis.expect(readyFromHelpers === readyFromOwnModule).toBe(true);
38
+ });
39
+ });
40
+
41
+ globalThis.describe("interceptor handler", () => {
42
+ globalThis.beforeEach(() => QueryDb_Callback$ReventlessCore.clearQueryInterceptor());
43
+ globalThis.test("no registered hook passes the read through", async () => {
44
+ let readModelName = "Product";
45
+ let allowed = await QueryInterceptor_Lambda$ReventlessAws.handler({
46
+ readModelName: readModelName,
47
+ arguments: null,
48
+ identity: identity
49
+ }, context);
50
+ globalThis.expect(allowed).toBe(true);
51
+ });
52
+ globalThis.test("an allowing hook is consulted and the read proceeds", async () => {
53
+ let seen = {
54
+ contents: []
55
+ };
56
+ QueryDb_Callback$ReventlessCore.registerQueryInterceptor(async (param, readModelName, param$1) => {
57
+ seen.contents = seen.contents.concat([readModelName]);
58
+ return "Allow";
59
+ });
60
+ let readModelName = "Order";
61
+ let allowed = await QueryInterceptor_Lambda$ReventlessAws.handler({
62
+ readModelName: readModelName,
63
+ arguments: null,
64
+ identity: identity
65
+ }, context);
66
+ globalThis.expect([
67
+ allowed,
68
+ seen.contents
69
+ ]).toEqual([
70
+ true,
71
+ ["Order"]
72
+ ]);
73
+ });
74
+ globalThis.test("a denying hook fails the read with the hook's own message", async () => {
75
+ QueryDb_Callback$ReventlessCore.registerQueryInterceptor(async (param, param$1, param$2) => ({
76
+ TAG: "Deny",
77
+ _0: "over the allowance"
78
+ }));
79
+ let message;
80
+ try {
81
+ let readModelName = "Product";
82
+ await QueryInterceptor_Lambda$ReventlessAws.handler({
83
+ readModelName: readModelName,
84
+ arguments: null,
85
+ identity: identity
86
+ }, context);
87
+ message = "did not throw";
88
+ } catch (raw_e) {
89
+ let e = Primitive_exceptions.internalToException(raw_e);
90
+ message = e.RE_EXN_ID === "JsExn" ? Stdlib_Option.getOr(Stdlib_JsExn.message(e._1), "no message") : "not a JS error";
91
+ }
92
+ globalThis.expect(message).toBe("over the allowance");
93
+ });
94
+ });
95
+
96
+ export {
97
+ readyFromOwnModule,
98
+ readyFromHelpers,
99
+ identity,
100
+ read,
101
+ context,
102
+ }
103
+ /* readyFromOwnModule Not a pure module */