@reventlessdev/reventless-aws 3.0.0-alpha.278 → 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.
- package/CHANGELOG.md +12 -0
- package/package.json +6 -6
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +58 -35
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs +21 -16
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +42 -20
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +13 -8
- package/src/adapter/QueryDb/QueryInterceptor_Lambda.res +21 -0
- package/src/adapter/QueryDb/QueryInterceptor_Lambda.res.mjs +6 -1
- package/src/adapter/Runtime/HandlerFactoryHelpers.mjs +5 -47
- package/src/adapter/Runtime/RuntimeExtensionsReady.mjs +59 -0
- package/src/adapter/StateTopic/StateTopic_AppSync.res +82 -7
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +17 -5
- package/src/adapter/StateTopic/StateTopic_AppSync_Helpers.res +36 -0
- package/src/adapter/StateTopic/StateTopic_AppSync_Helpers.res.mjs +17 -0
- package/tests/EventCollectorConnectLambdaPreviewTest.res +143 -0
- package/tests/EventCollectorConnectLambdaPreviewTest.res.mjs +101 -0
- package/tests/QueryInterceptor_LambdaTest.res +87 -0
- package/tests/QueryInterceptor_LambdaTest.res.mjs +103 -0
- package/tests/StateTopicKeySchemaTest.res +49 -0
- package/tests/StateTopicKeySchemaTest.res.mjs +40 -0
|
@@ -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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
21
|
-
let
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
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 */
|