@reventlessdev/reventless-aws 3.0.0-alpha.253 → 3.0.0-alpha.254

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 (36) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +9 -9
  3. package/src/Platform.res +134 -20
  4. package/src/Platform.res.mjs +95 -6
  5. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +5 -0
  6. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +3 -0
  7. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +5 -0
  8. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +3 -0
  9. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +66 -8
  10. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +16 -3
  11. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +5 -0
  12. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +3 -0
  13. package/src/adapter/Upload/Upload_Claim_S3.res +361 -0
  14. package/src/adapter/Upload/Upload_Claim_S3.res.mjs +203 -0
  15. package/src/adapter/Upload/Upload_Claim_S3_Ops.res +279 -0
  16. package/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs +261 -0
  17. package/src/adapter/Upload/Upload_PendingTag.res +41 -0
  18. package/src/adapter/Upload/Upload_PendingTag.res.mjs +15 -0
  19. package/src/adapter/Upload/Upload_Presign_S3.res +12 -2
  20. package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -0
  21. package/src/adapter/Upload/Upload_Presign_S3_Ops.res +13 -1
  22. package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +3 -1
  23. package/src/capability/Capability_ObjectStore_S3.res +42 -1
  24. package/src/capability/Capability_ObjectStore_S3.res.mjs +19 -1
  25. package/src/components/Api/AppSync_Adapter.res +36 -0
  26. package/src/components/Api/AppSync_Adapter.res.mjs +14 -0
  27. package/src/util/Util_LogRetention.res +86 -0
  28. package/src/util/Util_LogRetention.res.mjs +43 -0
  29. package/src/util/Util_StoreLayout.res +37 -0
  30. package/src/util/Util_StoreLayout.res.mjs +18 -0
  31. package/tests/Upload_ClaimTest.res +132 -0
  32. package/tests/Upload_ClaimTest.res.mjs +101 -0
  33. package/tests/Util_LogRetentionTest.res +122 -0
  34. package/tests/Util_LogRetentionTest.res.mjs +95 -0
  35. package/tests/Util_StoreLayoutTest.res +63 -0
  36. package/tests/Util_StoreLayoutTest.res.mjs +42 -0
@@ -20,6 +20,9 @@ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
20
20
  import * as Util_Lambda$ReventlessAws from "../../util/Util_Lambda.res.mjs";
21
21
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
22
22
  import * as Util_IAM_Role$ReventlessAws from "../../util/Util_IAM_Role.res.mjs";
23
+ import * as Util_LocalConfig$ReventlessAws from "../../util/Util_LocalConfig.res.mjs";
24
+ import * as Util_HostUiDomain$ReventlessAws from "../../util/Util_HostUiDomain.res.mjs";
25
+ import * as Util_LogRetention$ReventlessAws from "../../util/Util_LogRetention.res.mjs";
23
26
 
24
27
  let additionalEnvVars = {};
25
28
 
@@ -50,6 +53,9 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
50
53
  let timeout = timeoutOpt !== undefined ? timeoutOpt : Runtime$ReventlessCore.CommandHandlerDefaults.timeout;
51
54
  let dcbMetrics = dcbMetricsOpt !== undefined ? dcbMetricsOpt : false;
52
55
  let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
56
+ let stack = Pulumi.getStack();
57
+ let prodStacks = Util_HostUiDomain$ReventlessAws.resolveProdStacks();
58
+ let unmanagedStacks = Util_LogRetention$ReventlessAws.parseUnmanagedStacks(Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("unmanagedLogGroupStacks"), ""));
53
59
  let tagsFor = (resourceName, role) => AWS_Tags$ReventlessAws.make(resourceName, componentKind, role, undefined, name, undefined, undefined, undefined);
54
60
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), tagsFor(name, "Identity"), opts$1);
55
61
  Stdlib_Option.forEach(vpcConfig, param => {
@@ -87,7 +93,7 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
87
93
  let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
88
94
  let variables = Object.fromEntries([[
89
95
  "Environment",
90
- Pulumi.getStack()
96
+ stack
91
97
  ]]);
92
98
  Stdlib_Dict.forEachWithKey(envVars, (value, key) => {
93
99
  variables[key] = value;
@@ -95,6 +101,9 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
95
101
  Stdlib_Dict.forEachWithKey(additionalEnvVars, (value, key) => {
96
102
  variables[key] = value;
97
103
  });
104
+ if (Stdlib_Option.isNone(variables["LOG_LEVEL"])) {
105
+ variables["LOG_LEVEL"] = Util_LogRetention$ReventlessAws.logLevelFor(stack, prodStacks, Util_LocalConfig$ReventlessAws.get("logLevel"));
106
+ }
98
107
  variables["NODE_OPTIONS"] = Util_Bundle$ReventlessAws.esmLoaderNodeOptions;
99
108
  variables["ESM_FALLBACK_DIRS"] = Util_Bundle$ReventlessAws.esmFallbackDirs;
100
109
  let tags = tagsFor(name, "Runtime");
@@ -117,9 +126,13 @@ function makeFromCodeAsset(name, unitKind, componentKind, code, sourceCodeHash,
117
126
  })),
118
127
  vpcConfig: vpcConfig
119
128
  }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
120
- Stdlib_Option.forEach(logRetentionDays, days => {
129
+ let match = Util_LogRetention$ReventlessAws.managesLogGroup(stack, unmanagedStacks);
130
+ let managedRetention = logRetentionDays !== undefined ? logRetentionDays : (
131
+ match ? Util_LogRetention$ReventlessAws.retentionDaysFor(stack, prodStacks, Stdlib_Option.flatMap(Util_LocalConfig$ReventlessAws.get("logRetentionDays"), s => Stdlib_Int.fromString(s, undefined))) : undefined
132
+ );
133
+ Stdlib_Option.forEach(managedRetention, days => {
121
134
  let logGroup = new (Aws.cloudwatch.LogGroup)(name + `LogGroup`, {
122
- name: `/aws/lambda/` + name,
135
+ name: lambda.name.apply(n => `/aws/lambda/` + n),
123
136
  retentionInDays: days,
124
137
  tags: tagsFor(name + `LogGroup`, "Logs")
125
138
  }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
@@ -107,6 +107,11 @@ let forDcbCommandTopic = (
107
107
  }
108
108
  })
109
109
  )
110
+ // First-class log-level override → LOG_LEVEL, winning over any generic
111
+ // envVars entry. When unset, makeFromCodeAsset applies the tier default.
112
+ cfg.logLevel->Option.forEach(level =>
113
+ envVars->Dict.set("LOG_LEVEL", level->Pulumi.Output.make->Pulumi.Output.asInput)
114
+ )
110
115
 
111
116
  // Build HANDLER_CONFIG JSON: array of {spec, behavior} objects so the entry point
112
117
  // can dynamically import both modules and apply the curried StateChangeSlice_Callback.Make
@@ -53,6 +53,9 @@ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginNa
53
53
  return;
54
54
  }
55
55
  }));
56
+ Stdlib_Option.forEach(cfg.logLevel, level => {
57
+ envVars["LOG_LEVEL"] = Pulumi.output(level);
58
+ });
56
59
  let sliceModulesJson = slicePaths.map(param => {
57
60
  let s = Stdlib_Option.getOr(JSON.stringify(param[0]), `""`);
58
61
  let b = Stdlib_Option.getOr(JSON.stringify(param[1]), `""`);
@@ -0,0 +1,361 @@
1
+ // The claim component: an uploaded object stops being provisional when a
2
+ // committed event references it.
3
+ //
4
+ // Deploy-time half of [docs/plans/done/upload-pending-claim-and-expiry.md] Step 2.
5
+ // `make` registers one event log whose events declare at least one
6
+ // `@storageRef` field; `finish` builds the single Lambda that serves all of
7
+ // them, its stream subscriptions, its IAM, and the alarm on how far behind it
8
+ // is. Registered from the platform's subscription hook and finished from inside
9
+ // it, for the same reason `StateTopic_AppSync` is: the hook fires from within a
10
+ // `Pulumi.Output.apply` chain, so a `finish` called after `P.make()` returns
11
+ // would drain an empty registry.
12
+ //
13
+ // It is not a plugin's concern in the domain sense — a ref minted by one plugin
14
+ // can be referenced by another's event, and no plugin owns a store's contents —
15
+ // but it is provisioned per plugin stack, because that is where the event log
16
+ // tables and their streams exist. The store side (bucket and served prefix)
17
+ // crosses in from the platform stack, which is where stores are provisioned.
18
+ //
19
+ // Input breadth is the thing to guard. Only event logs with a declared
20
+ // ref-bearing field are registered, and within one only the declared fields are
21
+ // read; if this ever grows toward "read every event" it has become a projection
22
+ // and belongs in that machinery instead.
23
+
24
+ open PulumiAws
25
+
26
+ // ── Registry ─────────────────────────────────────────────────────────────────
27
+
28
+ type entry = {
29
+ /** The event log's DynamoDB table. Doubles as the routing key: the handler
30
+ recovers it from each record's `eventSourceARN`. */
31
+ tableName: Pulumi.Output.t<string>,
32
+ streamArn: Pulumi.Output.t<string>,
33
+ refFields: array<ReventlessCore.StorageRefFields.eventRefFields>,
34
+ }
35
+
36
+ /** One registry per plugin — one claimer Lambda per plugin stack. `make`
37
+ appends, `finish` drains. */
38
+ let registry: dict<array<entry>> = Dict.make()
39
+
40
+ /** Event logs that declare a ref-bearing field but publish through a channel
41
+ this component cannot read (an SNS-backed event topic rather than a
42
+ DynamoDB stream). Their objects stay tagged forever, which is safe while the
43
+ lifecycle rule is off and data loss once it is on — so the deploy says so. */
44
+ let unreachable: dict<array<string>> = Dict.make()
45
+
46
+ let log = ReventlessCore.Logger.fromEnv()
47
+
48
+ let appendTo = (reg: dict<array<'a>>, key: string, value: 'a) =>
49
+ reg->Dict.set(key, reg->Dict.get(key)->Option.getOr([])->Array.concat([value]))
50
+
51
+ // ── Registration ─────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ Register one event log with the plugin's claimer.
55
+
56
+ `~eventSchema` is read for `@storageRef` declarations; an event log with none is
57
+ not registered at all, so a platform whose plugins declare no store provisions
58
+ no claimer. `~plugin` qualifies a store the annotation left unqualified, which
59
+ is the same resolution the deploy used when it provisioned the store.
60
+
61
+ `~isStreamBacked` says whether the event log's topic publishes through a
62
+ DynamoDB stream — the default for both aggregate and DCB logs — rather than
63
+ SNS. Decided by the caller because the platform already makes exactly that
64
+ call synchronously from `EventTopicPublisher_SNS`'s registry; asking the
65
+ resource here would only get the answer inside an `apply`, which is after
66
+ `finish` has drained the registry.
67
+ */
68
+ let make = (
69
+ ~plugin: string,
70
+ ~eventLogName: string,
71
+ ~eventSchema: S.t<unknown>,
72
+ ~eventTopicOutputs: ReventlessInfra.EventTopic.outputs,
73
+ ~isStreamBacked: bool,
74
+ ) => {
75
+ let refFields = ReventlessCore.StorageRefFields.fromEventSchema(~plugin, eventSchema)
76
+ if refFields->Array.length > 0 {
77
+ // An SNS-backed topic carries the same events, but not on a channel this
78
+ // Lambda subscribes to. Recorded as a gap rather than skipped in silence:
79
+ // its objects would keep the pending tag forever, which reads as "working"
80
+ // right up until a store enables expiry.
81
+ switch isStreamBacked ? eventTopicOutputs.resources->Array.get(0) : None {
82
+ | None => unreachable->appendTo(plugin, eventLogName)
83
+ | Some(streamResource) =>
84
+ registry->appendTo(
85
+ plugin,
86
+ {
87
+ tableName: streamResource.name,
88
+ streamArn: streamResource->Util_DynamoDbStream.streamArnFromDynamoDbTableResource,
89
+ refFields,
90
+ },
91
+ )
92
+ }
93
+ }
94
+ }
95
+
96
+ // ── Finalize ─────────────────────────────────────────────────────────────────
97
+
98
+ /** A store the claimer may untag in, resolved to what the handler needs. */
99
+ type storeConfig = {
100
+ qualified: string,
101
+ bucketName: string,
102
+ servedPrefix: string,
103
+ }
104
+
105
+ /**
106
+ Build the plugin's claimer from everything `make` registered.
107
+
108
+ `~stores` is the platform's declared object stores — in a plugin stack they
109
+ arrive from the platform stack's `objectStores` export, which is why it is an
110
+ Output. A thunk rather than the Output itself because most plugins register
111
+ nothing here, and resolving another stack's export to build a claimer that is
112
+ not going to exist is work with no reader.
113
+
114
+ `~iteratorAgeAlarmMs` is the lag the alarm fires at. Lag is the one failure mode
115
+ that deletes data: if this Lambda stops and nobody notices, referenced objects
116
+ keep the tag and an enabled lifecycle rule expires them. The default is an hour
117
+ against an expiry measured in days, so a stall is noticed with room to fix it.
118
+ */
119
+ let finish = (
120
+ ~plugin: string,
121
+ ~stores: unit => Pulumi.Output.t<array<storeConfig>>,
122
+ ~iteratorAgeAlarmMs: float=3_600_000.,
123
+ ~opts: Pulumi.CustomResourceOptions.t,
124
+ ) => {
125
+ switch unreachable->Dict.get(plugin)->Option.filter(logs => logs->Array.length > 0) {
126
+ | Some(logs) =>
127
+ log.warn(
128
+ ~comp="Upload_Claim",
129
+ `event log(s) ${logs->Array.join(", ")} declare storage-ref fields but publish through an ` ++
130
+ `SNS event topic, which the claimer does not subscribe to — objects those events reference ` ++
131
+ `keep the pending tag. Do not enable a store's expiry rule while this is true.`,
132
+ )
133
+ unreachable->Dict.set(plugin, [])
134
+ | _ => ()
135
+ }
136
+
137
+ switch registry->Dict.get(plugin) {
138
+ | None | Some([]) => ()
139
+ | Some(entries) =>
140
+ let name = plugin ++ "UploadClaim"
141
+ let stores = stores()
142
+
143
+ let lambdaRole = IAM.Role.makeWithDefaultPolicy(
144
+ ~name=name ++ "Role",
145
+ ~servicePrincipal=AWS.Lambda.principal->Pulumi.Output.make,
146
+ ~tags=AWS.Tags.make(
147
+ ~name=name ++ "Role",
148
+ ~kind=ReventlessCore.ComponentType.Plugin,
149
+ ~role=Identity,
150
+ ~component=name,
151
+ ),
152
+ ~opts,
153
+ )
154
+
155
+ // Read every registered stream; tag and untag under every declared store's
156
+ // served prefix, never a whole bucket. `GetObjectTagging` is what makes the
157
+ // untag a read-modify-write that preserves a deployment's own tags rather
158
+ // than a `DeleteObjectTagging` that would take them with it.
159
+ let _policy =
160
+ (entries->Array.map(e => e.streamArn)->Pulumi.Output.all, stores)
161
+ ->Pulumi.Output.all2
162
+ ->Pulumi.Output.apply(((streamArns, stores)) => {
163
+ open PolicyDocument
164
+ let objectArns =
165
+ stores->Array.map(s => `arn:aws:s3:::${s.bucketName}/${s.servedPrefix}/*`)
166
+ let _rolePolicy = IAM.RolePolicy.make(
167
+ ~name=name ++ "Policy",
168
+ ~args={
169
+ policy: PolicyDocument.make(
170
+ ~id=name ++ "Policy",
171
+ ~statements=[
172
+ {
173
+ sid: "AllowLambdaLogging",
174
+ effect: Allow,
175
+ actions: Actions([
176
+ "logs:CreateLogGroup",
177
+ "logs:CreateLogStream",
178
+ "logs:PutLogEvents",
179
+ ]),
180
+ resources: Resource("arn:aws:logs:*:*:*"),
181
+ },
182
+ {
183
+ sid: "AllowReadEventLogStream",
184
+ effect: Allow,
185
+ actions: Actions([
186
+ "dynamodb:DescribeStream",
187
+ "dynamodb:GetRecords",
188
+ "dynamodb:GetShardIterator",
189
+ "dynamodb:ListStreams",
190
+ ]),
191
+ resources: Resources(streamArns),
192
+ },
193
+ {
194
+ sid: "AllowClaimObjectTagging",
195
+ effect: Allow,
196
+ actions: Actions(["s3:GetObjectTagging", "s3:PutObjectTagging"]),
197
+ resources: Resources(objectArns),
198
+ },
199
+ ],
200
+ )
201
+ ->PolicyDocument.toJsonString
202
+ ->Pulumi.Input.make,
203
+ role: lambdaRole.id->Pulumi.Output.asInput,
204
+ },
205
+ ~opts,
206
+ )
207
+ })
208
+
209
+ // `CLAIM_REF_FIELDS` — `{ <tableName>: { <eventType>: [refField] } }`. The
210
+ // whole of the handler's input: a table absent here is a table it reads no
211
+ // record from.
212
+ let refFieldsJson =
213
+ entries
214
+ ->Array.map(e => e.tableName)
215
+ ->Pulumi.Output.all
216
+ ->Pulumi.Output.apply(tableNames => {
217
+ let byTable = Dict.make()
218
+ tableNames->Array.forEachWithIndex((tableName, i) =>
219
+ byTable->Dict.set(
220
+ tableName,
221
+ ReventlessCore.StorageRefFields.toJson((entries->Array.getUnsafe(i)).refFields),
222
+ )
223
+ )
224
+ byTable->JSON.Encode.object->JSON.stringify
225
+ })
226
+
227
+ // `UPLOAD_STORES` — the same qualified-name → {bucket, prefix} shape the
228
+ // presign service reads, so mint and claim resolve a store identically.
229
+ let uploadStoresJson =
230
+ stores->Pulumi.Output.apply(stores =>
231
+ stores
232
+ ->Array.map(s => (
233
+ s.qualified,
234
+ Dict.fromArray([
235
+ ("bucket", JSON.Encode.string(s.bucketName)),
236
+ ("prefix", JSON.Encode.string(s.servedPrefix)),
237
+ ])->JSON.Encode.object,
238
+ ))
239
+ ->Dict.fromArray
240
+ ->JSON.Encode.object
241
+ ->JSON.stringify
242
+ )
243
+
244
+ // Compiled EntryPoint rather than a serialized closure, for the reason the
245
+ // presign service is one: the handler reaches the AWS SDK v3 S3 client, and
246
+ // a serialized closure bakes the deploy machine's SDK internals into an
247
+ // archive that then resolves its transitives from independently-versioned
248
+ // layer sources that disagree at cold start. The bundled `_Ops` module keeps
249
+ // bare `@aws-sdk/*` imports and resolves them through the resolve-hook, so
250
+ // one internally consistent SDK loads.
251
+ let packageDirs = Dict.fromArray([
252
+ (
253
+ "@reventlessdev/reventless-aws",
254
+ Util_Bundle.resolvePackageRoot("@reventlessdev/reventless-aws"),
255
+ ),
256
+ ])
257
+ let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
258
+ ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs",
259
+ ~packageDirs,
260
+ )
261
+
262
+ let layers =
263
+ Lambda.reventlessLayerArn
264
+ ->Option.map(arn => [arn->Pulumi.Input.make])
265
+ ->Option.getOr([])
266
+ ->Pulumi.Input.make
267
+
268
+ let lambda = Lambda.Function.make(
269
+ ~name,
270
+ ~args={
271
+ handler: "index.handler"->Pulumi.Input.make,
272
+ runtime: "nodejs22.x"->Pulumi.Input.make,
273
+ code: code->Pulumi.Input.make,
274
+ sourceCodeHash: sourceCodeHash->Pulumi.Input.make,
275
+ role: lambdaRole.arn->Pulumi.Output.asInput,
276
+ memorySize: 256->Pulumi.Input.make,
277
+ timeout: 60->Pulumi.Input.make,
278
+ layers,
279
+ tags: AWS.Tags.make(
280
+ ~name,
281
+ ~kind=ReventlessCore.ComponentType.Plugin,
282
+ ~role=Runtime,
283
+ ~component=name,
284
+ ),
285
+ environment: (
286
+ {
287
+ Lambda.Function.variables: Dict.fromArray([
288
+ ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
289
+ ("UPLOAD_STORES", uploadStoresJson->Pulumi.Output.asInput),
290
+ ("CLAIM_REF_FIELDS", refFieldsJson->Pulumi.Output.asInput),
291
+ ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
292
+ ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
293
+ ]),
294
+ }: Lambda.Function.functionEnvironment
295
+ )->Pulumi.Input.make,
296
+ },
297
+ ~opts,
298
+ )
299
+
300
+ // One mapping per event log stream, all targeting the one Lambda.
301
+ // `maximumRetryAttempts` bounds a permanently failing record so it cannot
302
+ // wedge the shard — the default of -1 retries forever, which would turn one
303
+ // bad record into unbounded claim lag, which is the failure that deletes
304
+ // data. `bisectBatchOnFunctionError` isolates it across those retries.
305
+ entries->Array.forEachWithIndex((entry, i) => {
306
+ let esmName = `${name}Stream${i->Int.toString}`
307
+ let _esm = EventSourceMapping.make(
308
+ ~name=esmName,
309
+ ~args={
310
+ EventSourceMapping.functionName: lambda.arn->Pulumi.Output.asInput,
311
+ eventSourceArn: entry.streamArn->Pulumi.Output.asInput,
312
+ startingPosition: LATEST,
313
+ maximumRetryAttempts: 3,
314
+ bisectBatchOnFunctionError: true,
315
+ tags: AWS.Tags.make(
316
+ ~name=esmName,
317
+ ~kind=ReventlessCore.ComponentType.Plugin,
318
+ ~role=EventSourceMapping,
319
+ ~component=name,
320
+ ),
321
+ },
322
+ ~opts=Some(opts),
323
+ )
324
+ })
325
+
326
+ // How far behind the claimer is, alarmed rather than merely emitted.
327
+ // `IteratorAge` is the stream consumer's own lag and needs no custom metric;
328
+ // `treatMissingData: "notBreaching"` keeps a quiet platform — no events, so
329
+ // no data points — out of alarm.
330
+ let _alarm = Cloudwatch.MetricAlarm.make(
331
+ ~name=name ++ "Lag",
332
+ ~args={
333
+ comparisonOperator: "GreaterThanThreshold"->Pulumi.Input.make,
334
+ evaluationPeriods: 3->Pulumi.Input.make,
335
+ metricName: "IteratorAge"->Pulumi.Input.make,
336
+ namespace: "AWS/Lambda"->Pulumi.Input.make,
337
+ period: 300->Pulumi.Input.make,
338
+ statistic: "Maximum"->Pulumi.Input.make,
339
+ threshold: iteratorAgeAlarmMs->Pulumi.Input.make,
340
+ dimensions: lambda.name
341
+ ->Pulumi.Output.apply(fn => Dict.fromArray([("FunctionName", fn)]))
342
+ ->Pulumi.Output.asInput,
343
+ treatMissingData: "notBreaching"->Pulumi.Input.make,
344
+ alarmDescription: (`${name} is behind on committed events. While it is behind, objects an ` ++
345
+ `event already references still carry the pending tag — and a store with an expiry rule ` ++
346
+ `enabled will delete them.`)->Pulumi.Input.make,
347
+ tags: AWS.Tags.make(
348
+ ~name=name ++ "Lag",
349
+ ~kind=ReventlessCore.ComponentType.Plugin,
350
+ ~role=Other("Alarm"),
351
+ ~component=name,
352
+ ),
353
+ },
354
+ ~opts,
355
+ )
356
+
357
+ // Drain, so a second construction in the same process (tests, dev reload)
358
+ // starts from empty rather than re-registering every stream.
359
+ registry->Dict.set(plugin, [])
360
+ }
361
+ }
@@ -0,0 +1,203 @@
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_DynamoDbStream$ReventlessAws from "../../util/Util_DynamoDbStream.res.mjs";
15
+
16
+ let registry = {};
17
+
18
+ let unreachable = {};
19
+
20
+ let log = Logger$ReventlessCore.fromEnv();
21
+
22
+ function appendTo(reg, key, value) {
23
+ reg[key] = Stdlib_Option.getOr(reg[key], []).concat([value]);
24
+ }
25
+
26
+ function make(plugin, eventLogName, eventSchema, eventTopicOutputs, isStreamBacked) {
27
+ let refFields = StorageRefFields$ReventlessCore.fromEventSchema(plugin, eventSchema);
28
+ if (refFields.length === 0) {
29
+ return;
30
+ }
31
+ let streamResource = isStreamBacked ? eventTopicOutputs.resources[0] : undefined;
32
+ if (streamResource !== undefined) {
33
+ return appendTo(registry, plugin, {
34
+ tableName: streamResource.name,
35
+ streamArn: Util_DynamoDbStream$ReventlessAws.streamArnFromDynamoDbTableResource(streamResource),
36
+ refFields: refFields
37
+ });
38
+ } else {
39
+ return appendTo(unreachable, plugin, eventLogName);
40
+ }
41
+ }
42
+
43
+ function finish(plugin, stores, iteratorAgeAlarmMsOpt, opts) {
44
+ let iteratorAgeAlarmMs = iteratorAgeAlarmMsOpt !== undefined ? iteratorAgeAlarmMsOpt : 3600000;
45
+ let logs = Stdlib_Option.filter(unreachable[plugin], logs => logs.length !== 0);
46
+ if (logs !== undefined) {
47
+ 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.`);
48
+ unreachable[plugin] = [];
49
+ }
50
+ let entries = registry[plugin];
51
+ if (entries === undefined) {
52
+ return;
53
+ }
54
+ if (entries.length === 0) {
55
+ return;
56
+ }
57
+ let name = plugin + "UploadClaim";
58
+ let stores$1 = stores();
59
+ 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);
60
+ Pulumi.all([
61
+ Pulumi.all(entries.map(e => e.streamArn)),
62
+ stores$1
63
+ ]).apply(param => {
64
+ let objectArns = param[1].map(s => `arn:aws:s3:::` + s.bucketName + `/` + s.servedPrefix + `/*`);
65
+ new (Aws.iam.RolePolicy)(name + "Policy", {
66
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "Policy", [
67
+ {
68
+ Sid: "AllowLambdaLogging",
69
+ Effect: "Allow",
70
+ Action: [
71
+ "logs:CreateLogGroup",
72
+ "logs:CreateLogStream",
73
+ "logs:PutLogEvents"
74
+ ],
75
+ Resource: "arn:aws:logs:*:*:*"
76
+ },
77
+ {
78
+ Sid: "AllowReadEventLogStream",
79
+ Effect: "Allow",
80
+ Action: [
81
+ "dynamodb:DescribeStream",
82
+ "dynamodb:GetRecords",
83
+ "dynamodb:GetShardIterator",
84
+ "dynamodb:ListStreams"
85
+ ],
86
+ Resource: param[0]
87
+ },
88
+ {
89
+ Sid: "AllowClaimObjectTagging",
90
+ Effect: "Allow",
91
+ Action: [
92
+ "s3:GetObjectTagging",
93
+ "s3:PutObjectTagging"
94
+ ],
95
+ Resource: objectArns
96
+ }
97
+ ])),
98
+ role: lambdaRole.id
99
+ }, opts);
100
+ });
101
+ let refFieldsJson = Pulumi.all(entries.map(e => e.tableName)).apply(tableNames => {
102
+ let byTable = {};
103
+ tableNames.forEach((tableName, i) => {
104
+ byTable[tableName] = StorageRefFields$ReventlessCore.toJson(entries[i].refFields);
105
+ });
106
+ return JSON.stringify(byTable);
107
+ });
108
+ let uploadStoresJson = stores$1.apply(stores => JSON.stringify(Object.fromEntries(stores.map(s => [
109
+ s.qualified,
110
+ Object.fromEntries([
111
+ [
112
+ "bucket",
113
+ s.bucketName
114
+ ],
115
+ [
116
+ "prefix",
117
+ s.servedPrefix
118
+ ]
119
+ ])
120
+ ]))));
121
+ let packageDirs = Object.fromEntries([[
122
+ "@reventlessdev/reventless-aws",
123
+ Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws")
124
+ ]]);
125
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs", packageDirs, undefined);
126
+ let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
127
+ let lambda = new (Aws.lambda.Function)(name, {
128
+ handler: "index.handler",
129
+ runtime: "nodejs22.x",
130
+ code: match.code,
131
+ role: lambdaRole.arn,
132
+ memorySize: 256,
133
+ timeout: 60,
134
+ layers: layers,
135
+ tags: AWS_Tags$ReventlessAws.make(name, "Plugin", "Runtime", undefined, name, undefined, undefined, undefined),
136
+ environment: {
137
+ variables: Object.fromEntries([
138
+ [
139
+ "Environment",
140
+ Pulumi.getStack()
141
+ ],
142
+ [
143
+ "UPLOAD_STORES",
144
+ uploadStoresJson
145
+ ],
146
+ [
147
+ "CLAIM_REF_FIELDS",
148
+ refFieldsJson
149
+ ],
150
+ [
151
+ "NODE_OPTIONS",
152
+ Util_Bundle$ReventlessAws.esmLoaderNodeOptions
153
+ ],
154
+ [
155
+ "ESM_FALLBACK_DIRS",
156
+ Util_Bundle$ReventlessAws.esmFallbackDirs
157
+ ]
158
+ ])
159
+ },
160
+ sourceCodeHash: match.sourceCodeHash
161
+ }, opts);
162
+ entries.forEach((entry, i) => {
163
+ let esmName = name + `Stream` + i.toString();
164
+ new (Aws.lambda.EventSourceMapping)(esmName, {
165
+ functionName: lambda.arn,
166
+ bisectBatchOnFunctionError: true,
167
+ eventSourceArn: entry.streamArn,
168
+ maximumRetryAttempts: 3,
169
+ startingPosition: "LATEST",
170
+ tags: AWS_Tags$ReventlessAws.make(esmName, "Plugin", "EventSourceMapping", undefined, name, undefined, undefined, undefined)
171
+ }, opts);
172
+ });
173
+ new (Aws.cloudwatch.MetricAlarm)(name + "Lag", {
174
+ comparisonOperator: "GreaterThanThreshold",
175
+ evaluationPeriods: 3,
176
+ metricName: "IteratorAge",
177
+ namespace: "AWS/Lambda",
178
+ period: 300,
179
+ statistic: "Maximum",
180
+ threshold: iteratorAgeAlarmMs,
181
+ dimensions: lambda.name.apply(fn => Object.fromEntries([[
182
+ "FunctionName",
183
+ fn
184
+ ]])),
185
+ treatMissingData: "notBreaching",
186
+ 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.`,
187
+ tags: AWS_Tags$ReventlessAws.make(name + "Lag", "Plugin", {
188
+ TAG: "Other",
189
+ _0: "Alarm"
190
+ }, undefined, name, undefined, undefined, undefined)
191
+ }, opts);
192
+ registry[plugin] = [];
193
+ }
194
+
195
+ export {
196
+ registry,
197
+ unreachable,
198
+ log,
199
+ appendTo,
200
+ make,
201
+ finish,
202
+ }
203
+ /* log Not a pure module */