@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +10 -10
  3. package/src/Platform.res +134 -20
  4. package/src/Platform.res.mjs +95 -6
  5. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +14 -0
  6. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +4 -1
  7. package/src/adapter/Api/Platform_UIFragments_Lambda.res +14 -0
  8. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +4 -1
  9. package/src/adapter/Geocoder/Geocoder_AwsLocation.res +14 -0
  10. package/src/adapter/Geocoder/Geocoder_AwsLocation.res.mjs +4 -1
  11. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +5 -0
  12. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +3 -0
  13. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +5 -0
  14. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +3 -0
  15. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +57 -8
  16. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +15 -3
  17. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +5 -0
  18. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +3 -0
  19. package/src/adapter/StateTopic/StateTopic_AppSync.res +14 -0
  20. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +4 -1
  21. package/src/adapter/Upload/Upload_Claim_S3.res +375 -0
  22. package/src/adapter/Upload/Upload_Claim_S3.res.mjs +206 -0
  23. package/src/adapter/Upload/Upload_Claim_S3_Ops.res +279 -0
  24. package/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs +261 -0
  25. package/src/adapter/Upload/Upload_PendingTag.res +41 -0
  26. package/src/adapter/Upload/Upload_PendingTag.res.mjs +15 -0
  27. package/src/adapter/Upload/Upload_Presign_S3.res +26 -2
  28. package/src/adapter/Upload/Upload_Presign_S3.res.mjs +5 -1
  29. package/src/adapter/Upload/Upload_Presign_S3_Ops.res +13 -1
  30. package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +3 -1
  31. package/src/capability/Capability_ObjectStore_S3.res +42 -1
  32. package/src/capability/Capability_ObjectStore_S3.res.mjs +19 -1
  33. package/src/components/Api/AppSync_Adapter.res +36 -0
  34. package/src/components/Api/AppSync_Adapter.res.mjs +14 -0
  35. package/src/util/Util_DeadLetterQueue.res +1 -0
  36. package/src/util/Util_DeadLetterQueue.res.mjs +6 -2
  37. package/src/util/Util_LambdaLogging.res +83 -0
  38. package/src/util/Util_LambdaLogging.res.mjs +59 -0
  39. package/src/util/Util_LogRetention.res +86 -0
  40. package/src/util/Util_LogRetention.res.mjs +43 -0
  41. package/src/util/Util_StoreLayout.res +37 -0
  42. package/src/util/Util_StoreLayout.res.mjs +18 -0
  43. package/tests/Upload_ClaimTest.res +132 -0
  44. package/tests/Upload_ClaimTest.res.mjs +101 -0
  45. package/tests/Util_LogRetentionTest.res +122 -0
  46. package/tests/Util_LogRetentionTest.res.mjs +95 -0
  47. package/tests/Util_StoreLayoutTest.res +63 -0
  48. 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
+ }