@reventlessdev/reventless-aws 3.0.0-alpha.253 → 3.0.0-alpha.255
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 +15 -0
- package/package.json +10 -10
- package/src/Platform.res +134 -20
- package/src/Platform.res.mjs +95 -6
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +14 -0
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +4 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +14 -0
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +4 -1
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res +14 -0
- package/src/adapter/Geocoder/Geocoder_AwsLocation.res.mjs +4 -1
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +5 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +3 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +5 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +3 -0
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +57 -8
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +15 -3
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +5 -0
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +3 -0
- package/src/adapter/StateTopic/StateTopic_AppSync.res +14 -0
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +4 -1
- package/src/adapter/Upload/Upload_Claim_S3.res +375 -0
- package/src/adapter/Upload/Upload_Claim_S3.res.mjs +206 -0
- package/src/adapter/Upload/Upload_Claim_S3_Ops.res +279 -0
- package/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs +261 -0
- package/src/adapter/Upload/Upload_PendingTag.res +41 -0
- package/src/adapter/Upload/Upload_PendingTag.res.mjs +15 -0
- package/src/adapter/Upload/Upload_Presign_S3.res +26 -2
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +5 -1
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res +13 -1
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +3 -1
- package/src/capability/Capability_ObjectStore_S3.res +42 -1
- package/src/capability/Capability_ObjectStore_S3.res.mjs +19 -1
- package/src/components/Api/AppSync_Adapter.res +36 -0
- package/src/components/Api/AppSync_Adapter.res.mjs +14 -0
- package/src/util/Util_DeadLetterQueue.res +1 -0
- package/src/util/Util_DeadLetterQueue.res.mjs +6 -2
- package/src/util/Util_LambdaLogging.res +83 -0
- package/src/util/Util_LambdaLogging.res.mjs +59 -0
- package/src/util/Util_LogRetention.res +86 -0
- package/src/util/Util_LogRetention.res.mjs +43 -0
- package/src/util/Util_StoreLayout.res +37 -0
- package/src/util/Util_StoreLayout.res.mjs +18 -0
- package/tests/Upload_ClaimTest.res +132 -0
- package/tests/Upload_ClaimTest.res.mjs +101 -0
- package/tests/Util_LogRetentionTest.res +122 -0
- package/tests/Util_LogRetentionTest.res.mjs +95 -0
- package/tests/Util_StoreLayoutTest.res +63 -0
- package/tests/Util_StoreLayoutTest.res.mjs +42 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Aws from "@pulumi/aws";
|
|
4
|
+
import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Pulumi from "@pulumi/pulumi";
|
|
7
|
+
import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
|
|
8
|
+
import * as AWS$ReventlessAws from "../AWS.res.mjs";
|
|
9
|
+
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
10
|
+
import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
|
|
11
|
+
import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
|
|
12
|
+
import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
|
|
13
|
+
import * as StorageRefFields$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/StorageRefFields.res.mjs";
|
|
14
|
+
import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
|
|
15
|
+
import * as Util_DynamoDbStream$ReventlessAws from "../../util/Util_DynamoDbStream.res.mjs";
|
|
16
|
+
|
|
17
|
+
let registry = {};
|
|
18
|
+
|
|
19
|
+
let unreachable = {};
|
|
20
|
+
|
|
21
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
22
|
+
|
|
23
|
+
function appendTo(reg, key, value) {
|
|
24
|
+
reg[key] = Stdlib_Option.getOr(reg[key], []).concat([value]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function make(plugin, eventLogName, eventSchema, eventTopicOutputs, isStreamBacked) {
|
|
28
|
+
let refFields = StorageRefFields$ReventlessCore.fromEventSchema(plugin, eventSchema);
|
|
29
|
+
if (refFields.length === 0) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let streamResource = isStreamBacked ? eventTopicOutputs.resources[0] : undefined;
|
|
33
|
+
if (streamResource !== undefined) {
|
|
34
|
+
return appendTo(registry, plugin, {
|
|
35
|
+
tableName: streamResource.name,
|
|
36
|
+
streamArn: Util_DynamoDbStream$ReventlessAws.streamArnFromDynamoDbTableResource(streamResource),
|
|
37
|
+
refFields: refFields
|
|
38
|
+
});
|
|
39
|
+
} else {
|
|
40
|
+
return appendTo(unreachable, plugin, eventLogName);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function finish(plugin, stores, iteratorAgeAlarmMsOpt, opts) {
|
|
45
|
+
let iteratorAgeAlarmMs = iteratorAgeAlarmMsOpt !== undefined ? iteratorAgeAlarmMsOpt : 3600000;
|
|
46
|
+
let logs = Stdlib_Option.filter(unreachable[plugin], logs => logs.length !== 0);
|
|
47
|
+
if (logs !== undefined) {
|
|
48
|
+
log.warn("Upload_Claim", undefined, `event log(s) ` + logs.join(", ") + ` declare storage-ref fields but publish through an SNS event topic, which the claimer does not subscribe to — objects those events reference keep the pending tag. Do not enable a store's expiry rule while this is true.`);
|
|
49
|
+
unreachable[plugin] = [];
|
|
50
|
+
}
|
|
51
|
+
let entries = registry[plugin];
|
|
52
|
+
if (entries === undefined) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (entries.length === 0) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let name = plugin + "UploadClaim";
|
|
59
|
+
let stores$1 = stores();
|
|
60
|
+
let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "Role", Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name + "Role", "Plugin", "Identity", undefined, name, undefined, undefined, undefined), opts);
|
|
61
|
+
Pulumi.all([
|
|
62
|
+
Pulumi.all(entries.map(e => e.streamArn)),
|
|
63
|
+
stores$1
|
|
64
|
+
]).apply(param => {
|
|
65
|
+
let objectArns = param[1].map(s => `arn:aws:s3:::` + s.bucketName + `/` + s.servedPrefix + `/*`);
|
|
66
|
+
new (Aws.iam.RolePolicy)(name + "Policy", {
|
|
67
|
+
policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "Policy", [
|
|
68
|
+
{
|
|
69
|
+
Sid: "AllowLambdaLogging",
|
|
70
|
+
Effect: "Allow",
|
|
71
|
+
Action: [
|
|
72
|
+
"logs:CreateLogGroup",
|
|
73
|
+
"logs:CreateLogStream",
|
|
74
|
+
"logs:PutLogEvents"
|
|
75
|
+
],
|
|
76
|
+
Resource: "arn:aws:logs:*:*:*"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
Sid: "AllowReadEventLogStream",
|
|
80
|
+
Effect: "Allow",
|
|
81
|
+
Action: [
|
|
82
|
+
"dynamodb:DescribeStream",
|
|
83
|
+
"dynamodb:GetRecords",
|
|
84
|
+
"dynamodb:GetShardIterator",
|
|
85
|
+
"dynamodb:ListStreams"
|
|
86
|
+
],
|
|
87
|
+
Resource: param[0]
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
Sid: "AllowClaimObjectTagging",
|
|
91
|
+
Effect: "Allow",
|
|
92
|
+
Action: [
|
|
93
|
+
"s3:GetObjectTagging",
|
|
94
|
+
"s3:PutObjectTagging"
|
|
95
|
+
],
|
|
96
|
+
Resource: objectArns
|
|
97
|
+
}
|
|
98
|
+
])),
|
|
99
|
+
role: lambdaRole.id
|
|
100
|
+
}, opts);
|
|
101
|
+
});
|
|
102
|
+
let refFieldsJson = Pulumi.all(entries.map(e => e.tableName)).apply(tableNames => {
|
|
103
|
+
let byTable = {};
|
|
104
|
+
tableNames.forEach((tableName, i) => {
|
|
105
|
+
byTable[tableName] = StorageRefFields$ReventlessCore.toJson(entries[i].refFields);
|
|
106
|
+
});
|
|
107
|
+
return JSON.stringify(byTable);
|
|
108
|
+
});
|
|
109
|
+
let uploadStoresJson = stores$1.apply(stores => JSON.stringify(Object.fromEntries(stores.map(s => [
|
|
110
|
+
s.qualified,
|
|
111
|
+
Object.fromEntries([
|
|
112
|
+
[
|
|
113
|
+
"bucket",
|
|
114
|
+
s.bucketName
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
"prefix",
|
|
118
|
+
s.servedPrefix
|
|
119
|
+
]
|
|
120
|
+
])
|
|
121
|
+
]))));
|
|
122
|
+
let packageDirs = Object.fromEntries([[
|
|
123
|
+
"@reventlessdev/reventless-aws",
|
|
124
|
+
Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws")
|
|
125
|
+
]]);
|
|
126
|
+
let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs", packageDirs, undefined);
|
|
127
|
+
let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
|
|
128
|
+
let lambda = new (Aws.lambda.Function)(name, {
|
|
129
|
+
handler: "index.handler",
|
|
130
|
+
runtime: "nodejs22.x",
|
|
131
|
+
code: match.code,
|
|
132
|
+
role: lambdaRole.arn,
|
|
133
|
+
memorySize: 256,
|
|
134
|
+
timeout: 60,
|
|
135
|
+
layers: layers,
|
|
136
|
+
tags: AWS_Tags$ReventlessAws.make(name, "Plugin", "Runtime", undefined, name, undefined, undefined, undefined),
|
|
137
|
+
environment: {
|
|
138
|
+
variables: Object.fromEntries([
|
|
139
|
+
[
|
|
140
|
+
"Environment",
|
|
141
|
+
Pulumi.getStack()
|
|
142
|
+
],
|
|
143
|
+
[
|
|
144
|
+
"UPLOAD_STORES",
|
|
145
|
+
uploadStoresJson
|
|
146
|
+
],
|
|
147
|
+
[
|
|
148
|
+
"CLAIM_REF_FIELDS",
|
|
149
|
+
refFieldsJson
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
"NODE_OPTIONS",
|
|
153
|
+
Util_Bundle$ReventlessAws.esmLoaderNodeOptions
|
|
154
|
+
],
|
|
155
|
+
[
|
|
156
|
+
"ESM_FALLBACK_DIRS",
|
|
157
|
+
Util_Bundle$ReventlessAws.esmFallbackDirs
|
|
158
|
+
],
|
|
159
|
+
Util_LambdaLogging$ReventlessAws.logLevelEntry()
|
|
160
|
+
])
|
|
161
|
+
},
|
|
162
|
+
sourceCodeHash: match.sourceCodeHash
|
|
163
|
+
}, opts);
|
|
164
|
+
Util_LambdaLogging$ReventlessAws.makeManagedLogGroup(name, lambda.name, AWS_Tags$ReventlessAws.make(name + "LogGroup", "Plugin", "Logs", undefined, name, undefined, undefined, undefined), opts, undefined);
|
|
165
|
+
entries.forEach((entry, i) => {
|
|
166
|
+
let esmName = name + `Stream` + i.toString();
|
|
167
|
+
new (Aws.lambda.EventSourceMapping)(esmName, {
|
|
168
|
+
functionName: lambda.arn,
|
|
169
|
+
bisectBatchOnFunctionError: true,
|
|
170
|
+
eventSourceArn: entry.streamArn,
|
|
171
|
+
maximumRetryAttempts: 3,
|
|
172
|
+
startingPosition: "LATEST",
|
|
173
|
+
tags: AWS_Tags$ReventlessAws.make(esmName, "Plugin", "EventSourceMapping", undefined, name, undefined, undefined, undefined)
|
|
174
|
+
}, opts);
|
|
175
|
+
});
|
|
176
|
+
new (Aws.cloudwatch.MetricAlarm)(name + "Lag", {
|
|
177
|
+
comparisonOperator: "GreaterThanThreshold",
|
|
178
|
+
evaluationPeriods: 3,
|
|
179
|
+
metricName: "IteratorAge",
|
|
180
|
+
namespace: "AWS/Lambda",
|
|
181
|
+
period: 300,
|
|
182
|
+
statistic: "Maximum",
|
|
183
|
+
threshold: iteratorAgeAlarmMs,
|
|
184
|
+
dimensions: lambda.name.apply(fn => Object.fromEntries([[
|
|
185
|
+
"FunctionName",
|
|
186
|
+
fn
|
|
187
|
+
]])),
|
|
188
|
+
treatMissingData: "notBreaching",
|
|
189
|
+
alarmDescription: name + ` is behind on committed events. While it is behind, objects an event already references still carry the pending tag — and a store with an expiry rule enabled will delete them.`,
|
|
190
|
+
tags: AWS_Tags$ReventlessAws.make(name + "Lag", "Plugin", {
|
|
191
|
+
TAG: "Other",
|
|
192
|
+
_0: "Alarm"
|
|
193
|
+
}, undefined, name, undefined, undefined, undefined)
|
|
194
|
+
}, opts);
|
|
195
|
+
registry[plugin] = [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export {
|
|
199
|
+
registry,
|
|
200
|
+
unreachable,
|
|
201
|
+
log,
|
|
202
|
+
appendTo,
|
|
203
|
+
make,
|
|
204
|
+
finish,
|
|
205
|
+
}
|
|
206
|
+
/* log Not a pure module */
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Runtime handler for the upload claim component — compiled, type-checked and
|
|
2
|
+
// Pulumi-free so it ships as an EntryPoint module (`Upload_Claim_S3` bundles it
|
|
3
|
+
// and attaches it to every ref-bearing event log's stream).
|
|
4
|
+
//
|
|
5
|
+
// It answers one question per committed event: has anything now referenced this
|
|
6
|
+
// uploaded object? If so the object stops being provisional, and the pending
|
|
7
|
+
// tag the mint side wrote comes off. An object nobody ever references keeps its
|
|
8
|
+
// tag, and a lifecycle rule — opt-in, and only once reconciliation has confirmed
|
|
9
|
+
// the tagged set is the unreferenced set — expires it.
|
|
10
|
+
//
|
|
11
|
+
// Triggered by DynamoDB streams over the event log tables, one shared Lambda for
|
|
12
|
+
// the whole platform: routing is per record via `CLAIM_REF_FIELDS`, keyed by the
|
|
13
|
+
// table name in `eventSourceARN`, exactly as `StateTopic_AppSync_Ops` routes its
|
|
14
|
+
// own shared Lambda. The event log row is `{event: "<Type>", data: {…}}`, so a
|
|
15
|
+
// row without an `event` attribute — a snapshot, a DCB fence row — is not an
|
|
16
|
+
// event and drops out before anything else runs.
|
|
17
|
+
//
|
|
18
|
+
// Four properties this needs, and one it deliberately does not:
|
|
19
|
+
//
|
|
20
|
+
// Idempotent untagging an untagged object, or one that is gone, is
|
|
21
|
+
// success. Replays and at-least-once delivery are non-events.
|
|
22
|
+
// Scoped it only ever touches keys under a declared store's served
|
|
23
|
+
// prefix; a ref pointing anywhere else is refused, not guessed.
|
|
24
|
+
// Observable lag is the one failure mode that deletes data, so the ESM's
|
|
25
|
+
// `IteratorAge` is alarmed at deploy time (see `Upload_Claim_S3`).
|
|
26
|
+
// Narrow its input is only event logs with a declared ref field. It is
|
|
27
|
+
// not a projection and must not grow into one.
|
|
28
|
+
// Not ordered the object exists before the event that references it — the
|
|
29
|
+
// PUT precedes the command — so there is no race to design for,
|
|
30
|
+
// and no consistent read is needed.
|
|
31
|
+
//
|
|
32
|
+
// Failure direction, deliberately: anything this handler cannot do leaves the
|
|
33
|
+
// tag on. A tag left behind delays nothing except an eventual expiry that is
|
|
34
|
+
// off by default; a tag wrongly removed is only ever a missed cleanup. The one
|
|
35
|
+
// unsafe direction — a claim that never runs while the rule is on — is what the
|
|
36
|
+
// lag alarm and the opt-in sequencing exist for.
|
|
37
|
+
|
|
38
|
+
module S3 = AwsSdk.S3
|
|
39
|
+
|
|
40
|
+
// ── Environment ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
let getEnv = (k: string): option<string> =>
|
|
43
|
+
switch NodeProcess.env->Dict.get(k) {
|
|
44
|
+
| Some("") | None => None
|
|
45
|
+
| Some(v) => Some(v)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let parseEnvObject = (k: string): dict<JSON.t> =>
|
|
49
|
+
switch getEnv(k)->Option.map(s => JSON.parseOrThrow(s)) {
|
|
50
|
+
| Some(Object(obj)) => obj
|
|
51
|
+
| _ => Dict.make()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A store the claimer may untag in: its physical bucket and the prefix its keys
|
|
55
|
+
are rooted at. Same `UPLOAD_STORES` shape the presign service reads, keyed by
|
|
56
|
+
the qualified `{plugin}.{store}` name a declaration resolves to. */
|
|
57
|
+
type storeConfig = {
|
|
58
|
+
bucket: string,
|
|
59
|
+
prefix: string,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let decodeStore = (json: JSON.t): option<storeConfig> =>
|
|
63
|
+
switch json {
|
|
64
|
+
| Object(obj) =>
|
|
65
|
+
switch (
|
|
66
|
+
obj->Dict.get("bucket")->Option.flatMap(JSON.Decode.string),
|
|
67
|
+
obj->Dict.get("prefix")->Option.flatMap(JSON.Decode.string),
|
|
68
|
+
) {
|
|
69
|
+
| (Some(bucket), Some(prefix)) => Some({bucket, prefix})
|
|
70
|
+
| _ => None
|
|
71
|
+
}
|
|
72
|
+
| _ => None
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let stores: dict<storeConfig> =
|
|
76
|
+
parseEnvObject("UPLOAD_STORES")
|
|
77
|
+
->Dict.toArray
|
|
78
|
+
->Array.filterMap(((k, v)) => decodeStore(v)->Option.map(c => (k, c)))
|
|
79
|
+
->Dict.fromArray
|
|
80
|
+
|
|
81
|
+
/** One declared ref-bearing field, as `StorageRefFields.toJson` wrote it. */
|
|
82
|
+
type refField = {
|
|
83
|
+
field: string,
|
|
84
|
+
many: bool,
|
|
85
|
+
store: string,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let decodeRefField = (json: JSON.t): option<refField> =>
|
|
89
|
+
switch json {
|
|
90
|
+
| Object(obj) =>
|
|
91
|
+
switch (
|
|
92
|
+
obj->Dict.get("field")->Option.flatMap(JSON.Decode.string),
|
|
93
|
+
obj->Dict.get("store")->Option.flatMap(JSON.Decode.string),
|
|
94
|
+
) {
|
|
95
|
+
| (Some(field), Some(store)) =>
|
|
96
|
+
Some({
|
|
97
|
+
field,
|
|
98
|
+
many: obj->Dict.get("arity")->Option.flatMap(JSON.Decode.string) == Some("many"),
|
|
99
|
+
store,
|
|
100
|
+
})
|
|
101
|
+
| _ => None
|
|
102
|
+
}
|
|
103
|
+
| _ => None
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let decodeEventFields = (json: JSON.t): dict<array<refField>> =>
|
|
107
|
+
switch json {
|
|
108
|
+
| Object(obj) =>
|
|
109
|
+
obj
|
|
110
|
+
->Dict.toArray
|
|
111
|
+
->Array.map(((eventType, v)) => (
|
|
112
|
+
eventType,
|
|
113
|
+
switch v {
|
|
114
|
+
| Array(items) => items->Array.filterMap(decodeRefField)
|
|
115
|
+
| _ => []
|
|
116
|
+
},
|
|
117
|
+
))
|
|
118
|
+
->Dict.fromArray
|
|
119
|
+
| _ => Dict.make()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `{ "<eventLogTableName>": { "<eventType>": [refField] } }`, baked at deploy
|
|
123
|
+
time from the plugins' `@storageRef` declarations. Its keys are the whole of
|
|
124
|
+
this component's input: a table absent from the map is a table it never
|
|
125
|
+
reads a record from. */
|
|
126
|
+
let refFieldsByTable: dict<dict<array<refField>>> =
|
|
127
|
+
parseEnvObject("CLAIM_REF_FIELDS")
|
|
128
|
+
->Dict.toArray
|
|
129
|
+
->Array.map(((table, v)) => (table, decodeEventFields(v)))
|
|
130
|
+
->Dict.fromArray
|
|
131
|
+
|
|
132
|
+
// ── Pure derivations ────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** Table name out of a stream ARN — `…:table/<TableName>/stream/<ts>`. */
|
|
135
|
+
let tableNameFromEventSourceArn = (arn: string): option<string> => {
|
|
136
|
+
let parts = arn->String.split("/")
|
|
137
|
+
switch (parts->Array.get(0), parts->Array.get(1), parts->Array.get(2)) {
|
|
138
|
+
| (Some(prefix), Some(tableName), Some("stream")) if prefix->String.endsWith(":table") =>
|
|
139
|
+
Some(tableName)
|
|
140
|
+
| _ => None
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The ref strings a declared field holds in one event payload.
|
|
145
|
+
|
|
146
|
+
Non-string values and the `""` sentinel (which `StorageRef` admits to mean
|
|
147
|
+
"no object") yield nothing, so a field that is present but empty costs no
|
|
148
|
+
S3 call. */
|
|
149
|
+
let refsOfField = (~data: dict<JSON.t>, field: refField): array<string> =>
|
|
150
|
+
switch data->Dict.get(field.field) {
|
|
151
|
+
| Some(String(ref)) if !field.many && ref != "" => [ref]
|
|
152
|
+
| Some(Array(items)) if field.many =>
|
|
153
|
+
items->Array.filterMap(v =>
|
|
154
|
+
switch v {
|
|
155
|
+
| String(ref) if ref != "" => Some(ref)
|
|
156
|
+
| _ => None
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
| _ => []
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Strip the leading `/` a ref carries (`/{prefix}/{key}`) to recover the S3
|
|
163
|
+
object key. Mirrors the presign service, which mints `/${key}`. */
|
|
164
|
+
let keyOfRef = (storageRef: string): string =>
|
|
165
|
+
storageRef->String.startsWith("/")
|
|
166
|
+
? storageRef->String.slice(~start=1, ~end=storageRef->String.length)
|
|
167
|
+
: storageRef
|
|
168
|
+
|
|
169
|
+
/** An object this claim may touch: a declared store, and a key that actually
|
|
170
|
+
sits under that store's served prefix.
|
|
171
|
+
|
|
172
|
+
The prefix check is the same one the release rule makes, for the same
|
|
173
|
+
reason — a ref that resolves outside the store it names is refused rather
|
|
174
|
+
than acted on, so a malformed or hostile ref cannot steer an untag at an
|
|
175
|
+
object the declaration does not cover. */
|
|
176
|
+
type target = {
|
|
177
|
+
bucket: string,
|
|
178
|
+
key: string,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let resolveTarget = (~store: string, ~storageRef: string): result<target, string> =>
|
|
182
|
+
switch stores->Dict.get(store) {
|
|
183
|
+
| None => Error(`unknown_store ${store}`)
|
|
184
|
+
| Some({bucket, prefix}) =>
|
|
185
|
+
let key = storageRef->keyOfRef
|
|
186
|
+
key->String.startsWith(`${prefix}/`)
|
|
187
|
+
? Ok({bucket, key})
|
|
188
|
+
: Error(`not_in_store ${store} ${storageRef}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── The claim ───────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/** Remove the pending tag, keeping every other tag the object carries.
|
|
194
|
+
Read-then-write rather than `DeleteObjectTagging`, which would take a
|
|
195
|
+
deployment's own tags with it.
|
|
196
|
+
|
|
197
|
+
Returns without writing when the tag is already absent — the replay case,
|
|
198
|
+
and the common one — so a redelivered batch costs one read per object and
|
|
199
|
+
no write. A missing object is success for the same reason a missing object
|
|
200
|
+
is a successful release: the outcome asked for has already happened. */
|
|
201
|
+
let claim = async (~bucket: string, ~key: string): unit =>
|
|
202
|
+
try {
|
|
203
|
+
let current = await S3.GetObjectTaggingCommand.send(
|
|
204
|
+
S3.GetObjectTaggingCommand.make({bucket, key}),
|
|
205
|
+
)
|
|
206
|
+
let tagSet = current.tagSet->Option.getOr([])
|
|
207
|
+
let remaining = tagSet->Array.filter(t => t.key != Upload_PendingTag.key)
|
|
208
|
+
if remaining->Array.length != tagSet->Array.length {
|
|
209
|
+
let _ = await S3.PutObjectTaggingCommand.send(
|
|
210
|
+
S3.PutObjectTaggingCommand.make({bucket, key, tagging: {tagSet: remaining}}),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
| exn =>
|
|
215
|
+
switch exn->JsExn.fromException->Option.flatMap(JsExn.name) {
|
|
216
|
+
| Some("NoSuchKey") | Some("NotFound") => ()
|
|
217
|
+
| _ => throw(exn)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── DynamoDB stream event (only the fields this handler reads) ──────────────
|
|
222
|
+
|
|
223
|
+
type attributeValue = AwsSdk.DynamoDb.Util.attributeValue
|
|
224
|
+
type streamRecord = {@as("NewImage") newImage?: dict<attributeValue>}
|
|
225
|
+
type record = {
|
|
226
|
+
eventName: string,
|
|
227
|
+
eventSourceARN: string,
|
|
228
|
+
dynamodb?: streamRecord,
|
|
229
|
+
}
|
|
230
|
+
type event = {@as("Records") records: array<record>}
|
|
231
|
+
|
|
232
|
+
/** The refs one stream record claims, already resolved to a bucket and key.
|
|
233
|
+
|
|
234
|
+
Pure and separately testable: everything between "a row appeared" and "an
|
|
235
|
+
S3 call happens" is decided here, so the untag itself has no branching left
|
|
236
|
+
in it. */
|
|
237
|
+
let targetsOfRecord = (record: record): array<target> =>
|
|
238
|
+
switch (
|
|
239
|
+
record.eventSourceARN->tableNameFromEventSourceArn->Option.flatMap(t => refFieldsByTable->Dict.get(t)),
|
|
240
|
+
record.dynamodb->Option.flatMap(d => d.newImage),
|
|
241
|
+
) {
|
|
242
|
+
| (Some(byEventType), Some(image)) =>
|
|
243
|
+
let row = AwsSdk.DynamoDb_Util_Helpers.unmarshallDict(image)
|
|
244
|
+
// No `event` attribute → not an event row (a snapshot, a DCB fence row).
|
|
245
|
+
switch (row->Dict.get("event")->Option.flatMap(JSON.Decode.string), row->Dict.get("data")) {
|
|
246
|
+
| (Some(eventType), Some(JSON.Object(data))) =>
|
|
247
|
+
byEventType
|
|
248
|
+
->Dict.get(eventType)
|
|
249
|
+
->Option.getOr([])
|
|
250
|
+
->Array.flatMap(field =>
|
|
251
|
+
refsOfField(~data, field)->Array.filterMap(storageRef =>
|
|
252
|
+
switch resolveTarget(~store=field.store, ~storageRef) {
|
|
253
|
+
| Ok(target) => Some(target)
|
|
254
|
+
| Error(why) =>
|
|
255
|
+
// Refused, not acted on — and said out loud, because a ref that
|
|
256
|
+
// does not resolve is a declaration and a deploy disagreeing.
|
|
257
|
+
Console.error2("Upload_Claim: refusing ref outside a declared store —", why)
|
|
258
|
+
None
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
| _ => []
|
|
263
|
+
}
|
|
264
|
+
| _ => []
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Runtime handler ─────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
let handler = async (event: event): unit => {
|
|
270
|
+
// An append-only log only ever inserts events; MODIFY rows are fence and
|
|
271
|
+
// snapshot updates, which `targetsOfRecord` would drop anyway.
|
|
272
|
+
let targets = event.records->Array.filter(r => r.eventName == "INSERT")->Array.flatMap(targetsOfRecord)
|
|
273
|
+
// Sequential: a batch carries a handful of refs at most, and a failure must
|
|
274
|
+
// reach the ESM as a throw so the record is retried rather than silently
|
|
275
|
+
// leaving an object tagged.
|
|
276
|
+
await targets->Array.reduce(Promise.resolve(), (acc, {bucket, key}) =>
|
|
277
|
+
acc->Promise.then(_ => claim(~bucket, ~key))
|
|
278
|
+
)
|
|
279
|
+
}
|