@reventlessdev/reventless-aws 3.0.0-alpha.336 → 3.0.0-alpha.338

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.
@@ -62,11 +62,11 @@ external scanByTableName: (
62
62
  let exnMessage = (exn: exn): string =>
63
63
  exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
64
64
 
65
- // ── js_nullable normalization ───────────────────────────────────────────────
66
- // pluginDefinition option fields use @s.matches(js_nullable): when the record
67
- // comes from a plain JSON.parse (not a sury parse), None is represented as
68
- // `null` — which ReScript's option (undefined-based) would misread as Some.
69
- // Normalize through Nullable so pattern matches see a real option.
65
+ // ── Absent-field normalization ──────────────────────────────────────────────
66
+ // pluginDefinition's optionals omit the key, so a plain JSON.parse (not a sury
67
+ // parse) yields `undefined` and ReScript reads it as None unaided. Kept because
68
+ // the same records also arrive from stores and peers written when an absent
69
+ // field was `null`, which ReScript's option would misread as Some.
70
70
 
71
71
  external optionAsNullable: option<'a> => Nullable.t<'a> = "%identity"
72
72
  let jsOption = (v: option<'a>): option<'a> => v->optionAsNullable->Nullable.toOption
@@ -237,10 +237,9 @@ let parseHandlerConfig = (rawJson: string): handlerConfig => {
237
237
 
238
238
  // ── Plugin-RM row projection ────────────────────────────────────────────────
239
239
  // The subset of the Plugin read-model state the subscription manager needs.
240
- // Deliberately sidesteps sury: the state schema marks many fields
241
- // `@s.matches(js_nullable …)`, but DDB drops undefined attributes on write, so
242
- // the unmarshalled row arrives with those keys *missing* and sury's strict
243
- // parser rejects them. Field types reuse the spec's definition records — the
240
+ // Deliberately sidesteps sury: DDB drops undefined attributes on write, so the
241
+ // unmarshalled row can arrive with keys sury's strict parser still expects — the
242
+ // offload fields among them, whose codec keeps its `null` arm. Field types reuse the spec's definition records — the
244
243
  // projection is a strict subset of Reventless.Plugin.pluginDefinition.
245
244
 
246
245
  type pluginProjection = {
@@ -325,8 +324,8 @@ let projectPluginRow = (row: JSON.t): option<pluginProjection> =>
325
324
 
326
325
  // A pluginDefinition viewed as a projection (the manage logic reads only the
327
326
  // shared subset; `status` is never read for the acting plugin). Array/option
328
- // fields stay js_nullable-normalized: a definition arriving over the wire, rather
329
- // than from loadPluginDefinition's decode, still carries null for an absent field.
327
+ // fields stay normalized: a definition arriving over the wire, rather than from
328
+ // loadPluginDefinition's decode, may still carry null for an absent field.
330
329
  let projectionOfDefinition = (d: Reventless.Plugin.pluginDefinition): pluginProjection => {
331
330
  id: d.id,
332
331
  status: "",
@@ -0,0 +1,155 @@
1
+ // What to alarm on, per kind of execution unit — the pure half of the CloudWatch
2
+ // monitoring backend. `Monitoring_CloudWatch` turns these into resources; every
3
+ // decision worth arguing with is here, where a test can reach it without
4
+ // importing @pulumi/pulumi. Same split as Util_LogRetention / Util_LambdaLogging.
5
+
6
+ module M = ReventlessCore.Monitoring
7
+
8
+ /** One alarm. `suffix` distinguishes several alarms on one unit; the empty
9
+ string is the unit's primary alarm. */
10
+ type t = {
11
+ metricName: string,
12
+ namespace: string,
13
+ /** CloudWatch operator name, e.g. `"GreaterThanOrEqualToThreshold"`. */
14
+ comparisonOperator: string,
15
+ threshold: float,
16
+ /** Seconds. */
17
+ period: int,
18
+ evaluationPeriods: int,
19
+ statistic: string,
20
+ /** `"missing"` | `"notBreaching"` | `"breaching"` | `"ignore"`. */
21
+ treatMissingData: string,
22
+ suffix: string,
23
+ /** One clause, for the description a person reads in the notification. */
24
+ meaning: string,
25
+ }
26
+
27
+ /** How long a scheduled unit may produce no invocation before that silence is
28
+ itself the fault. The default is an hour: the plugin heartbeat's default
29
+ interval is 5 minutes, so an hour is twelve missed beats — far enough out that
30
+ a slow deploy or a throttle cannot trip it, close enough that a stopped
31
+ scheduler is caught within the hour rather than by its consequences days
32
+ later. Deployments that set a long `heartbeatInterval` should raise it. */
33
+ let defaultSilenceWindowSeconds = 60 * 60
34
+
35
+ let errorsAlarm = {
36
+ metricName: "Errors",
37
+ namespace: "AWS/Lambda",
38
+ comparisonOperator: "GreaterThanOrEqualToThreshold",
39
+ threshold: 1.0,
40
+ period: 300,
41
+ evaluationPeriods: 1,
42
+ statistic: "Sum",
43
+ // A unit that is not running produces no datapoints, and "no traffic" is not a
44
+ // failure — an idle stack must not sit permanently in alarm, or every alarm it
45
+ // raises is discounted.
46
+ treatMissingData: "notBreaching",
47
+ suffix: "",
48
+ meaning: "is failing on its messages (Errors >= 1 in 5min)",
49
+ }
50
+
51
+ /**
52
+ The alarms for one kind of execution unit. More than one is allowed, because for
53
+ some kinds a single metric cannot express the fault.
54
+
55
+ - **Dead-letter sink** — the metric is `Invocations`, not `Errors`. This handler
56
+ fails on every delivery by design, so `Errors` says only that it is doing its
57
+ job; that it ran at all is the incident.
58
+ - **Scheduler** — two alarms, and the second is the one that matters. A heartbeat
59
+ that *stops* emits no errors and no invocations, so the absence is the fault and
60
+ `Errors` cannot see it. The silence alarm inverts the test (`Invocations < 1`)
61
+ and treats missing data as breaching, which is the only combination that fires
62
+ on a metric that has stopped being published. This is the exact shape that went
63
+ unnoticed on a deployed estate: plugins stopped heartbeating and nothing said so.
64
+ - **Everything else** — `Errors >= 1`.
65
+ */
66
+ let forKind = (~kind: M.unitKind, ~silenceWindowSeconds=defaultSilenceWindowSeconds): array<t> =>
67
+ switch kind {
68
+ | DeadLetterSink => [
69
+ {
70
+ ...errorsAlarm,
71
+ metricName: "Invocations",
72
+ meaning: "received a dead letter (an invocation here IS the incident)",
73
+ },
74
+ ]
75
+ | Scheduler => [
76
+ errorsAlarm,
77
+ {
78
+ ...errorsAlarm,
79
+ metricName: "Invocations",
80
+ comparisonOperator: "LessThanThreshold",
81
+ period: silenceWindowSeconds,
82
+ // The point of the alarm: a metric that stopped being published is the
83
+ // failure, so its absence must breach rather than be excused.
84
+ treatMissingData: "breaching",
85
+ suffix: "Silent",
86
+ meaning: "has not run for " ++ (silenceWindowSeconds / 60)->Int.toString ++ " minutes",
87
+ },
88
+ ]
89
+ | _ => [errorsAlarm]
90
+ }
91
+
92
+ /** Lower-case, hyphen-free rendering of a kind, for a resource name and for the
93
+ machine-readable `kind=` in a description. `Other` carries its own word. */
94
+ let kindSlug = (kind: M.unitKind): string =>
95
+ switch kind {
96
+ | CommandHandler => "commandhandler"
97
+ | Projection => "projection"
98
+ | Reactor => "reactor"
99
+ | EventCollector => "eventcollector"
100
+ | Task => "task"
101
+ | Scheduler => "scheduler"
102
+ | DeadLetterSink => "deadlettersink"
103
+ | Other(word) => "other" ++ word->String.toLowerCase
104
+ }
105
+
106
+ /**
107
+ The alarm's logical (Pulumi) resource name.
108
+
109
+ Carries the plugin when there is one: `~name` is the component only, and two
110
+ plugins in a platform can own like-named components — a collision here is a
111
+ Pulumi duplicate-resource failure at deploy time, not a subtle one, but the fix
112
+ belongs in the name rather than in the reader's memory.
113
+ */
114
+ let resourceName = (~kind: M.unitKind, ~name: string, ~plugin: option<string>, ~suffix: string) => {
115
+ let owner = switch plugin {
116
+ | Some(p) => p ++ "-"
117
+ | None => ""
118
+ }
119
+ `alarm-${kind->kindSlug}-${owner}${name}${suffix}`
120
+ }
121
+
122
+ /**
123
+ What the notification says.
124
+
125
+ Two audiences in one string, because a CloudWatch state-change message carries the
126
+ description and neither the alarm's tags nor its name: a sentence for the person
127
+ who reads the mail, and a bracketed token for anything parsing it. Without the
128
+ platform the mail cannot say which estate fired — one platform runs many plugins
129
+ whose components are named by role, so `CatalogPluginHeartbeat` alone is ambiguous
130
+ across stacks.
131
+ */
132
+ let description = (
133
+ ~kind: M.unitKind,
134
+ ~name: string,
135
+ ~plugin: option<string>,
136
+ ~platform: option<string>,
137
+ ~spec: t,
138
+ ~logs: option<string>,
139
+ ) => {
140
+ let owner = switch (plugin, platform) {
141
+ | (Some(p), Some(pl)) => ` (plugin ${p}, platform ${pl})`
142
+ | (Some(p), None) => ` (plugin ${p})`
143
+ | (None, Some(pl)) => ` (platform ${pl})`
144
+ // Platform substrate, owned by no plugin — a dead-letter queue is shared by
145
+ // every plugin in the estate, so naming one would be a lie.
146
+ | (None, None) => ""
147
+ }
148
+ let logsClause = switch logs {
149
+ | Some(group) => ` Logs: ${group}.`
150
+ | None => ""
151
+ }
152
+ `Reventless: ${kind->kindSlug} '${name}'${owner} ${spec.meaning}.${logsClause}` ++
153
+ ` [reventless plugin=${plugin->Option.getOr("")} platform=${platform->Option.getOr("")}` ++
154
+ ` component=${name} kind=${kind->kindSlug}]`
155
+ }
@@ -0,0 +1,108 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+
5
+ let errorsAlarm = {
6
+ metricName: "Errors",
7
+ namespace: "AWS/Lambda",
8
+ comparisonOperator: "GreaterThanOrEqualToThreshold",
9
+ threshold: 1.0,
10
+ period: 300,
11
+ evaluationPeriods: 1,
12
+ statistic: "Sum",
13
+ treatMissingData: "notBreaching",
14
+ suffix: "",
15
+ meaning: "is failing on its messages (Errors >= 1 in 5min)"
16
+ };
17
+
18
+ function forKind(kind, silenceWindowSecondsOpt) {
19
+ let silenceWindowSeconds = silenceWindowSecondsOpt !== undefined ? silenceWindowSecondsOpt : 3600;
20
+ if (typeof kind === "object") {
21
+ return [errorsAlarm];
22
+ }
23
+ switch (kind) {
24
+ case "Scheduler" :
25
+ return [
26
+ errorsAlarm,
27
+ {
28
+ metricName: "Invocations",
29
+ namespace: "AWS/Lambda",
30
+ comparisonOperator: "LessThanThreshold",
31
+ threshold: 1.0,
32
+ period: silenceWindowSeconds,
33
+ evaluationPeriods: 1,
34
+ statistic: "Sum",
35
+ treatMissingData: "breaching",
36
+ suffix: "Silent",
37
+ meaning: "has not run for " + (silenceWindowSeconds / 60 | 0).toString() + " minutes"
38
+ }
39
+ ];
40
+ case "DeadLetterSink" :
41
+ return [{
42
+ metricName: "Invocations",
43
+ namespace: "AWS/Lambda",
44
+ comparisonOperator: "GreaterThanOrEqualToThreshold",
45
+ threshold: 1.0,
46
+ period: 300,
47
+ evaluationPeriods: 1,
48
+ statistic: "Sum",
49
+ treatMissingData: "notBreaching",
50
+ suffix: "",
51
+ meaning: "received a dead letter (an invocation here IS the incident)"
52
+ }];
53
+ default:
54
+ return [errorsAlarm];
55
+ }
56
+ }
57
+
58
+ function kindSlug(kind) {
59
+ if (typeof kind === "object") {
60
+ return "other" + kind._0.toLowerCase();
61
+ }
62
+ switch (kind) {
63
+ case "CommandHandler" :
64
+ return "commandhandler";
65
+ case "Projection" :
66
+ return "projection";
67
+ case "Reactor" :
68
+ return "reactor";
69
+ case "EventCollector" :
70
+ return "eventcollector";
71
+ case "Task" :
72
+ return "task";
73
+ case "Scheduler" :
74
+ return "scheduler";
75
+ case "DeadLetterSink" :
76
+ return "deadlettersink";
77
+ }
78
+ }
79
+
80
+ function resourceName(kind, name, plugin, suffix) {
81
+ let owner = plugin !== undefined ? plugin + "-" : "";
82
+ return `alarm-` + kindSlug(kind) + `-` + owner + name + suffix;
83
+ }
84
+
85
+ function description(kind, name, plugin, platform, spec, logs) {
86
+ let owner = plugin !== undefined ? (
87
+ platform !== undefined ? ` (plugin ` + plugin + `, platform ` + platform + `)` : ` (plugin ` + plugin + `)`
88
+ ) : (
89
+ platform !== undefined ? ` (platform ` + platform + `)` : ""
90
+ );
91
+ let logsClause = logs !== undefined ? ` Logs: ` + logs + `.` : "";
92
+ return `Reventless: ` + kindSlug(kind) + ` '` + name + `'` + owner + ` ` + spec.meaning + `.` + logsClause + (` [reventless plugin=` + Stdlib_Option.getOr(plugin, "") + ` platform=` + Stdlib_Option.getOr(platform, "")) + (` component=` + name + ` kind=` + kindSlug(kind) + `]`);
93
+ }
94
+
95
+ let M;
96
+
97
+ let defaultSilenceWindowSeconds = 3600;
98
+
99
+ export {
100
+ M,
101
+ defaultSilenceWindowSeconds,
102
+ errorsAlarm,
103
+ forKind,
104
+ kindSlug,
105
+ resourceName,
106
+ description,
107
+ }
108
+ /* No side effect */
@@ -17,10 +17,25 @@ let queueTags = queueName =>
17
17
  // 4-day default can expire it over a long weekend before anyone reads it.
18
18
  let retentionSeconds = 14 * 24 * 60 * 60
19
19
 
20
+ // A dead letter has no latency requirement, and the handler below fails on
21
+ // purpose — so a message returns to the queue and is redelivered until retention
22
+ // expires. At 180 s that is ~480 redeliveries per message per day, which outlives
23
+ // the fault by however long nobody looks: seven heartbeats stranded by a since-fixed
24
+ // decode error were still cycling twelve hours after the cause was cured. Fifteen
25
+ // minutes cuts that 5× and costs nothing anybody is waiting on. It must stay above
26
+ // the handler's timeout (30 s), which it is by a wide margin.
27
+ //
28
+ // It bounds the rate, not the count — the loop still ends only at retention. What
29
+ // ends it early is an operator, and what fetches one is an alarm on this handler's
30
+ // `Invocations` (an invocation here IS the incident). That the alarm does not
31
+ // currently fire is a defect in when this module announces itself, not a reason
32
+ // for the queue to carry a redrive target of its own.
33
+ let visibilityTimeoutSeconds = 15 * 60
34
+
20
35
  let queue = SQS.Queue.make(
21
36
  ~name,
22
37
  ~args={
23
- SQS.Queue.visibilityTimeoutSeconds: 180->Pulumi.Input.make,
38
+ SQS.Queue.visibilityTimeoutSeconds: visibilityTimeoutSeconds->Pulumi.Input.make,
24
39
  messageRetentionSeconds: retentionSeconds->Pulumi.Input.make,
25
40
  sqsManagedSseEnabled: false->Pulumi.Input.make,
26
41
  tags: queueTags(name),
@@ -32,7 +47,7 @@ let fifoQueue = SQS.Queue.make(
32
47
  ~args={
33
48
  SQS.Queue.fifoQueue: true->Pulumi.Input.make,
34
49
  contentBasedDeduplication: true->Pulumi.Input.make,
35
- visibilityTimeoutSeconds: 180->Pulumi.Input.make,
50
+ visibilityTimeoutSeconds: visibilityTimeoutSeconds->Pulumi.Input.make,
36
51
  messageRetentionSeconds: retentionSeconds->Pulumi.Input.make,
37
52
  sqsManagedSseEnabled: false->Pulumi.Input.make,
38
53
  tags: queueTags(nameFifo),
@@ -61,16 +76,41 @@ let lambdaRole = IAM.Role.makeWithDefaultPolicy(
61
76
  // plugin whose commands failed every 5 minutes for two days produced 217 dead
62
77
  // letters and no signal at all; it was found by a person noticing a stale UI.
63
78
  //
64
- // Failing instead keeps the message on the queue (this queue has no redrive
65
- // target of its own, so it stays until retention expires) and keeps `Errors`
66
- // non-zero for as long as the condition lasts. Both are conventional alarm
67
- // subjects, and a monitoring backend attached through the `DeadLetterSink` seam
68
- // below now has something to attach to. Re-delivery re-logs the payload; on a
69
- // queue that is empty in normal operation, that repetition is the alert.
79
+ // Failing instead keeps the message on the queue and keeps `Errors` non-zero
80
+ // while the condition lasts. Both are conventional alarm subjects, and a
81
+ // monitoring backend attached through the `DeadLetterSink` seam below now has
82
+ // something to attach to.
83
+ //
84
+ // What that reasoning did not account for is that the transport retries by
85
+ // design too: a failed batch returns to the queue and is redelivered until
86
+ // retention expires. A handler that fails forever, on a transport that retries
87
+ // forever, is a loop — and it outlives its cause, because nothing about a fixed
88
+ // bug removes the message that was stranded by it. Seven of them were still
89
+ // cycling twelve hours after the fix that made them impossible.
90
+ //
91
+ // What the loop costs is now a function of the line, not the count: the full
92
+ // record is written once — on the first delivery, the one carrying the diagnostic
93
+ // — and every redelivery after it costs a single identity line. A stranded
94
+ // message is ~150 KB over a fortnight rather than ~17 MB, which is small enough
95
+ // that ending the loop early is an operator's job rather than the topology's.
70
96
  let entryPointCode = `export const handler = async (event) => {
71
- console.error("DEAD LETTER ITEM:", JSON.stringify(event));
97
+ const records = event?.Records ?? [];
98
+ for (const record of records) {
99
+ const attrs = record?.attributes ?? {};
100
+ const receiveCount = Number(attrs.ApproximateReceiveCount ?? 1);
101
+ const identity =
102
+ "messageId=" + (record?.messageId ?? "unknown") +
103
+ " source=" + (attrs.DeadLetterQueueSourceArn ?? record?.eventSourceARN ?? "unknown") +
104
+ " receiveCount=" + receiveCount +
105
+ " bodyBytes=" + (record?.body?.length ?? 0);
106
+ if (receiveCount <= 1) {
107
+ console.error("DEAD LETTER ITEM: " + identity, JSON.stringify(record));
108
+ } else {
109
+ console.error("DEAD LETTER REDELIVERY: " + identity);
110
+ }
111
+ }
72
112
  throw new Error(
73
- "Dead-lettered " + (event?.Records?.length ?? 0) +
113
+ "Dead-lettered " + records.length +
74
114
  " message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
75
115
  );
76
116
  };`
@@ -89,6 +129,24 @@ let layers =
89
129
  ->Option.getOr([])
90
130
  ->Pulumi.Input.make
91
131
 
132
+ // This is the one Lambda in the framework built by hand rather than through
133
+ // `RuntimeEnvironment_Lambda`, and it was the one Lambda whose logs Lambda
134
+ // auto-created a group for — which carries no retention, so every byte this
135
+ // handler ever wrote was kept forever. On the estate that surfaced it, the three
136
+ // such groups held 1.3 GB of a redelivery loop. Same managed group, same tiering
137
+ // as every other handler.
138
+ let logGroup = Util_LambdaLogging.makeManagedLogGroup(
139
+ ~name,
140
+ ~tags=AWS.Tags.make(
141
+ ~name=`${name}LogGroup`,
142
+ ~kind=ReventlessCore.ComponentType.Plugin,
143
+ ~role=Logs,
144
+ ~scope=Plugin,
145
+ ),
146
+ ~opts,
147
+ (),
148
+ )
149
+
92
150
  let handler = Lambda.Function.make(
93
151
  ~name,
94
152
  ~args={
@@ -109,6 +167,7 @@ let handler = Lambda.Function.make(
109
167
  ]),
110
168
  }: Lambda.Function.functionEnvironment
111
169
  )->Pulumi.Input.make,
170
+ loggingConfig: ?Util_LambdaLogging.loggingConfigFor(logGroup),
112
171
  },
113
172
  ~opts,
114
173
  )
@@ -124,9 +183,7 @@ ReventlessCore.Monitoring.notify(
124
183
  ~kind=DeadLetterSink,
125
184
  ~name,
126
185
  ~component=deadLetterResource,
127
- // This handler is built without a managed group, so its logs are in the one
128
- // Lambda auto-creates from the physical name.
129
- ~logLocator=Util_LambdaLogging.logLocatorFor(~logGroup=None, ~physicalName=deadLetterResource.name),
186
+ ~logLocator=Util_LambdaLogging.logLocatorFor(~logGroup, ~physicalName=deadLetterResource.name),
130
187
  )
131
188
 
132
189
  let lambda = handler->Pulumi.Output.make
@@ -25,7 +25,7 @@ function queueTags(queueName) {
25
25
  let queue = new (Aws.sqs.Queue)(name, {
26
26
  messageRetentionSeconds: 1209600,
27
27
  tags: queueTags(name),
28
- visibilityTimeoutSeconds: 180,
28
+ visibilityTimeoutSeconds: 900,
29
29
  sqsManagedSseEnabled: false
30
30
  });
31
31
 
@@ -34,7 +34,7 @@ let fifoQueue = new (Aws.sqs.Queue)(nameFifo, {
34
34
  fifoQueue: true,
35
35
  messageRetentionSeconds: 1209600,
36
36
  tags: queueTags(nameFifo),
37
- visibilityTimeoutSeconds: 180,
37
+ visibilityTimeoutSeconds: 900,
38
38
  sqsManagedSseEnabled: false
39
39
  });
40
40
 
@@ -47,9 +47,23 @@ let opts = {
47
47
  let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Plugin", "Identity", "Plugin", undefined, undefined, undefined, undefined), opts);
48
48
 
49
49
  let entryPointCode = `export const handler = async (event) => {
50
- console.error("DEAD LETTER ITEM:", JSON.stringify(event));
50
+ const records = event?.Records ?? [];
51
+ for (const record of records) {
52
+ const attrs = record?.attributes ?? {};
53
+ const receiveCount = Number(attrs.ApproximateReceiveCount ?? 1);
54
+ const identity =
55
+ "messageId=" + (record?.messageId ?? "unknown") +
56
+ " source=" + (attrs.DeadLetterQueueSourceArn ?? record?.eventSourceARN ?? "unknown") +
57
+ " receiveCount=" + receiveCount +
58
+ " bodyBytes=" + (record?.body?.length ?? 0);
59
+ if (receiveCount <= 1) {
60
+ console.error("DEAD LETTER ITEM: " + identity, JSON.stringify(record));
61
+ } else {
62
+ console.error("DEAD LETTER REDELIVERY: " + identity);
63
+ }
64
+ }
51
65
  throw new Error(
52
- "Dead-lettered " + (event?.Records?.length ?? 0) +
66
+ "Dead-lettered " + records.length +
53
67
  " message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
54
68
  );
55
69
  };`;
@@ -64,6 +78,8 @@ let sourceCodeHash = Util_Bundle$ReventlessAws.hashString(entryPointCode);
64
78
 
65
79
  let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
66
80
 
81
+ let logGroup = Util_LambdaLogging$ReventlessAws.makeManagedLogGroup(name, undefined, AWS_Tags$ReventlessAws.make(name + `LogGroup`, "Plugin", "Logs", "Plugin", undefined, undefined, undefined, undefined), opts, undefined);
82
+
67
83
  let handler = new (Aws.lambda.Function)(name, {
68
84
  handler: "index.handler",
69
85
  runtime: "nodejs22.x",
@@ -82,12 +98,13 @@ let handler = new (Aws.lambda.Function)(name, {
82
98
  Util_LambdaLogging$ReventlessAws.logLevelEntry()
83
99
  ])
84
100
  },
85
- sourceCodeHash: sourceCodeHash
101
+ sourceCodeHash: sourceCodeHash,
102
+ loggingConfig: Util_LambdaLogging$ReventlessAws.loggingConfigFor(logGroup)
86
103
  }, opts);
87
104
 
88
105
  let deadLetterResource = Util_Lambda$ReventlessAws.functionToResource(AWS_Tags$ReventlessAws.make(name, "Plugin", "DeadLetter", "Plugin", undefined, undefined, undefined, undefined), handler);
89
106
 
90
- Monitoring$ReventlessCore.notify("DeadLetterSink", name, deadLetterResource, Util_LambdaLogging$ReventlessAws.logLocatorFor(undefined, deadLetterResource.name));
107
+ Monitoring$ReventlessCore.notify("DeadLetterSink", name, deadLetterResource, Util_LambdaLogging$ReventlessAws.logLocatorFor(logGroup, deadLetterResource.name));
91
108
 
92
109
  let lambda = Pulumi.output(handler);
93
110
 
@@ -164,11 +181,14 @@ Pulumi.all([
164
181
 
165
182
  let retentionSeconds = 1209600;
166
183
 
184
+ let visibilityTimeoutSeconds = 900;
185
+
167
186
  export {
168
187
  name,
169
188
  nameFifo,
170
189
  queueTags,
171
190
  retentionSeconds,
191
+ visibilityTimeoutSeconds,
172
192
  queue,
173
193
  fifoQueue,
174
194
  opts,
@@ -178,6 +198,7 @@ export {
178
198
  code,
179
199
  sourceCodeHash,
180
200
  layers,
201
+ logGroup,
181
202
  handler,
182
203
  deadLetterResource,
183
204
  lambda,
@@ -93,10 +93,24 @@ let fields = (
93
93
  ~computed: array<(string, JSON.t)>,
94
94
  ~viewModes: option<array<Platform.viewMode>>=?,
95
95
  ~bakedManifest: option<Platform.bakedManifest>=?,
96
+ ~uiSlotsFile: option<string>=?,
96
97
  ~shellConfig: option<dict<JSON.t>>=?,
97
98
  ): dict<JSON.t> => {
98
99
  let out = computed->Dict.fromArray
99
100
 
101
+ // The shell imports the module this key names, and imports nothing when the
102
+ // key is absent — so writing the object without naming it here would ship a
103
+ // file nothing fetches. Computed rather than left to `shellConfig` for the
104
+ // reason `manifestUrl` is: the deploy decides where the object goes, and a
105
+ // passthrough pointing the shell elsewhere would be a 404 the shell is
106
+ // designed to survive quietly.
107
+ uiSlotsFile->Option.forEach(_ =>
108
+ out->Dict.set(
109
+ ReventlessCore.Platform_UiSlots.configKey,
110
+ JSON.Encode.string(ReventlessCore.Platform_UiSlots.url),
111
+ )
112
+ )
113
+
100
114
  // A declared bake is what turns the shell's non-elevated audience on: an
101
115
  // operator keeps the admin queries, everyone else discovers from this file.
102
116
  // Computed rather than left to `shellConfig` because the deploy is what
@@ -4,6 +4,7 @@ import * as Pervasives from "@rescript/runtime/lib/es6/Pervasives.js";
4
4
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Platform$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/Platform.res.mjs";
7
+ import * as Platform_UiSlots$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UiSlots.res.mjs";
7
8
  import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
8
9
 
9
10
  let journeyManifestsKey = "journeyManifestUrls";
@@ -56,8 +57,11 @@ function subscriptionEndpoint(httpsEndpoint) {
56
57
  return httpsEndpoint.replace("https://", "wss://").replace(".appsync-api.", ".appsync-realtime-api.");
57
58
  }
58
59
 
59
- function fields(computed, viewModes, bakedManifest, shellConfig) {
60
+ function fields(computed, viewModes, bakedManifest, uiSlotsFile, shellConfig) {
60
61
  let out = Object.fromEntries(computed);
62
+ Stdlib_Option.forEach(uiSlotsFile, param => {
63
+ out[Platform_UiSlots$ReventlessCore.configKey] = Platform_UiSlots$ReventlessCore.url;
64
+ });
61
65
  Stdlib_Option.forEach(bakedManifest, bake => {
62
66
  out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(bake.key);
63
67
  let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(bake);
@@ -73,6 +73,27 @@ let readJsonFileVerbatim = (~path: string, ~label: string): string => {
73
73
  }
74
74
  }
75
75
 
76
+ /**
77
+ * Read a file at deploy time and return its exact bytes as a string, with no
78
+ * opinion about what is in them. A missing file throws; a present one ships as
79
+ * written.
80
+ *
81
+ * The counterpart to `readJsonFileVerbatim` for content this deploy cannot
82
+ * validate. A JSON file either parses or it does not, and checking costs
83
+ * nothing. An ES module is only known to be good once a browser has evaluated
84
+ * it, and there is no cheap check in between: parsing it here would need a
85
+ * JavaScript parser this deploy does not have, and a syntax check would still
86
+ * say nothing about whether it registers anything. So the deploy checks the one
87
+ * thing it can — that the declared path names a file — and leaves the rest to
88
+ * the consumer, which reports what it could not use.
89
+ */
90
+ let readFileVerbatim = (~path: string, ~label: string): string => {
91
+ if !NodeFs.existsSync(path) {
92
+ JsError.throwWithMessage(`${label}: file does not exist: ${path}`)
93
+ }
94
+ NodeFs.readFileSync(path)
95
+ }
96
+
76
97
  /**
77
98
  * Replace `/` and `.` so a path can be used as a Pulumi resource URN segment.
78
99
  */
@@ -65,6 +65,13 @@ function readJsonFileVerbatim(path, label) {
65
65
  }
66
66
  }
67
67
 
68
+ function readFileVerbatim(path, label) {
69
+ if (!Nodefs.existsSync(path)) {
70
+ Stdlib_JsError.throwWithMessage(label + `: file does not exist: ` + path);
71
+ }
72
+ return Nodefs.readFileSync(path, "utf8");
73
+ }
74
+
68
75
  function sanitizeName(relativePath) {
69
76
  return relativePath.replaceAll("/", "-").replaceAll(".", "-");
70
77
  }
@@ -139,6 +146,7 @@ export {
139
146
  walkInto,
140
147
  walk,
141
148
  readJsonFileVerbatim,
149
+ readFileVerbatim,
142
150
  sanitizeName,
143
151
  extensionOf,
144
152
  contentTypeFor,