@reventlessdev/reventless-aws 3.0.0-alpha.252 → 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 (38) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +159 -21
  4. package/src/Platform.res.mjs +120 -8
  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/Task/TaskBucket_S3.res +11 -0
  14. package/src/adapter/Task/TaskBucket_S3.res.mjs +3 -0
  15. package/src/adapter/Upload/Upload_Claim_S3.res +361 -0
  16. package/src/adapter/Upload/Upload_Claim_S3.res.mjs +203 -0
  17. package/src/adapter/Upload/Upload_Claim_S3_Ops.res +279 -0
  18. package/src/adapter/Upload/Upload_Claim_S3_Ops.res.mjs +261 -0
  19. package/src/adapter/Upload/Upload_PendingTag.res +41 -0
  20. package/src/adapter/Upload/Upload_PendingTag.res.mjs +15 -0
  21. package/src/adapter/Upload/Upload_Presign_S3.res +12 -2
  22. package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -0
  23. package/src/adapter/Upload/Upload_Presign_S3_Ops.res +17 -1
  24. package/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs +3 -1
  25. package/src/capability/Capability_ObjectStore_S3.res +42 -1
  26. package/src/capability/Capability_ObjectStore_S3.res.mjs +19 -1
  27. package/src/components/Api/AppSync_Adapter.res +36 -0
  28. package/src/components/Api/AppSync_Adapter.res.mjs +14 -0
  29. package/src/util/Util_LogRetention.res +86 -0
  30. package/src/util/Util_LogRetention.res.mjs +43 -0
  31. package/src/util/Util_StoreLayout.res +62 -9
  32. package/src/util/Util_StoreLayout.res.mjs +20 -2
  33. package/tests/Upload_ClaimTest.res +132 -0
  34. package/tests/Upload_ClaimTest.res.mjs +101 -0
  35. package/tests/Util_LogRetentionTest.res +122 -0
  36. package/tests/Util_LogRetentionTest.res.mjs +95 -0
  37. package/tests/Util_StoreLayoutTest.res +79 -3
  38. package/tests/Util_StoreLayoutTest.res.mjs +47 -2
@@ -0,0 +1,261 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
9
+ import * as ClientS3 from "@aws-sdk/client-s3";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
+ import * as DynamoDb_Util_Helpers$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_Util_Helpers.res.mjs";
12
+ import * as Upload_PendingTag$ReventlessAws from "./Upload_PendingTag.res.mjs";
13
+
14
+ function getEnv(k) {
15
+ let v = process.env[k];
16
+ if (v !== undefined && v !== "") {
17
+ return v;
18
+ }
19
+ }
20
+
21
+ function parseEnvObject(k) {
22
+ let match = Stdlib_Option.map(getEnv(k), s => JSON.parse(s));
23
+ if (match !== undefined) {
24
+ if (typeof match === "object" && match !== null && !Array.isArray(match)) {
25
+ return match;
26
+ } else {
27
+ return {};
28
+ }
29
+ } else {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ function decodeStore(json) {
35
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
36
+ return;
37
+ }
38
+ let match = Stdlib_Option.flatMap(json["bucket"], Stdlib_JSON.Decode.string);
39
+ let match$1 = Stdlib_Option.flatMap(json["prefix"], Stdlib_JSON.Decode.string);
40
+ if (match !== undefined && match$1 !== undefined) {
41
+ return {
42
+ bucket: match,
43
+ prefix: match$1
44
+ };
45
+ }
46
+ }
47
+
48
+ let stores = Object.fromEntries(Stdlib_Array.filterMap(Object.entries(parseEnvObject("UPLOAD_STORES")), param => {
49
+ let k = param[0];
50
+ return Stdlib_Option.map(decodeStore(param[1]), c => [
51
+ k,
52
+ c
53
+ ]);
54
+ }));
55
+
56
+ function decodeRefField(json) {
57
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
58
+ return;
59
+ }
60
+ let match = Stdlib_Option.flatMap(json["field"], Stdlib_JSON.Decode.string);
61
+ let match$1 = Stdlib_Option.flatMap(json["store"], Stdlib_JSON.Decode.string);
62
+ if (match !== undefined && match$1 !== undefined) {
63
+ return {
64
+ field: match,
65
+ many: Primitive_object.equal(Stdlib_Option.flatMap(json["arity"], Stdlib_JSON.Decode.string), "many"),
66
+ store: match$1
67
+ };
68
+ }
69
+ }
70
+
71
+ function decodeEventFields(json) {
72
+ if (typeof json === "object" && json !== null && !Array.isArray(json)) {
73
+ return Object.fromEntries(Object.entries(json).map(param => {
74
+ let v = param[1];
75
+ let tmp;
76
+ tmp = Array.isArray(v) ? Stdlib_Array.filterMap(v, decodeRefField) : [];
77
+ return [
78
+ param[0],
79
+ tmp
80
+ ];
81
+ }));
82
+ } else {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ let refFieldsByTable = Object.fromEntries(Object.entries(parseEnvObject("CLAIM_REF_FIELDS")).map(param => [
88
+ param[0],
89
+ decodeEventFields(param[1])
90
+ ]));
91
+
92
+ function tableNameFromEventSourceArn(arn) {
93
+ let parts = arn.split("/");
94
+ let match = parts[0];
95
+ let match$1 = parts[1];
96
+ let match$2 = parts[2];
97
+ if (match !== undefined && match$1 !== undefined && match$2 !== undefined && match$2 === "stream" && match.endsWith(":table")) {
98
+ return match$1;
99
+ }
100
+ }
101
+
102
+ function refsOfField(data, field) {
103
+ let match = data[field.field];
104
+ if (match === undefined) {
105
+ return [];
106
+ }
107
+ if (Array.isArray(match)) {
108
+ if (field.many) {
109
+ return Stdlib_Array.filterMap(match, v => {
110
+ if (typeof v === "string" && v !== "") {
111
+ return v;
112
+ }
113
+ });
114
+ } else {
115
+ return [];
116
+ }
117
+ }
118
+ switch (typeof match) {
119
+ case "string" :
120
+ if (!field.many && match !== "") {
121
+ return [match];
122
+ } else {
123
+ return [];
124
+ }
125
+ default:
126
+ return [];
127
+ }
128
+ }
129
+
130
+ function keyOfRef(storageRef) {
131
+ if (storageRef.startsWith("/")) {
132
+ return storageRef.slice(1, storageRef.length);
133
+ } else {
134
+ return storageRef;
135
+ }
136
+ }
137
+
138
+ function resolveTarget(store, storageRef) {
139
+ let match = stores[store];
140
+ if (match === undefined) {
141
+ return {
142
+ TAG: "Error",
143
+ _0: `unknown_store ` + store
144
+ };
145
+ }
146
+ let key = keyOfRef(storageRef);
147
+ if (key.startsWith(match.prefix + `/`)) {
148
+ return {
149
+ TAG: "Ok",
150
+ _0: {
151
+ bucket: match.bucket,
152
+ key: key
153
+ }
154
+ };
155
+ } else {
156
+ return {
157
+ TAG: "Error",
158
+ _0: `not_in_store ` + store + ` ` + storageRef
159
+ };
160
+ }
161
+ }
162
+
163
+ async function claim(bucket, key) {
164
+ try {
165
+ let current = await S3$AwsSdk.GetObjectTaggingCommand.send(new ClientS3.GetObjectTaggingCommand({
166
+ Bucket: bucket,
167
+ Key: key
168
+ }));
169
+ let tagSet = Stdlib_Option.getOr(current.TagSet, []);
170
+ let remaining = tagSet.filter(t => t.Key !== Upload_PendingTag$ReventlessAws.key);
171
+ if (remaining.length !== tagSet.length) {
172
+ await S3$AwsSdk.PutObjectTaggingCommand.send(new ClientS3.PutObjectTaggingCommand({
173
+ Bucket: bucket,
174
+ Key: key,
175
+ Tagging: {
176
+ TagSet: remaining
177
+ }
178
+ }));
179
+ return;
180
+ } else {
181
+ return;
182
+ }
183
+ } catch (raw_exn) {
184
+ let exn = Primitive_exceptions.internalToException(raw_exn);
185
+ let match = Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.name);
186
+ if (match !== undefined) {
187
+ switch (match) {
188
+ case "NoSuchKey" :
189
+ case "NotFound" :
190
+ return;
191
+ default:
192
+ throw exn;
193
+ }
194
+ } else {
195
+ throw exn;
196
+ }
197
+ }
198
+ }
199
+
200
+ function targetsOfRecord(record) {
201
+ let match = Stdlib_Option.flatMap(tableNameFromEventSourceArn(record.eventSourceARN), t => refFieldsByTable[t]);
202
+ let match$1 = Stdlib_Option.flatMap(record.dynamodb, d => d.NewImage);
203
+ if (match === undefined) {
204
+ return [];
205
+ }
206
+ if (match$1 === undefined) {
207
+ return [];
208
+ }
209
+ let row = DynamoDb_Util_Helpers$AwsSdk.unmarshallDict(undefined, match$1);
210
+ let match$2 = Stdlib_Option.flatMap(row["event"], Stdlib_JSON.Decode.string);
211
+ let match$3 = row["data"];
212
+ if (match$2 !== undefined) {
213
+ if (match$3 !== undefined) {
214
+ if (typeof match$3 === "object" && match$3 !== null && !Array.isArray(match$3)) {
215
+ return Stdlib_Option.getOr(match[match$2], []).flatMap(field => Stdlib_Array.filterMap(refsOfField(match$3, field), storageRef => {
216
+ let target = resolveTarget(field.store, storageRef);
217
+ if (target.TAG === "Ok") {
218
+ return target._0;
219
+ }
220
+ console.error("Upload_Claim: refusing ref outside a declared store —", target._0);
221
+ }));
222
+ } else {
223
+ return [];
224
+ }
225
+ } else {
226
+ return [];
227
+ }
228
+ } else {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ async function handler(event) {
234
+ let targets = event.Records.filter(r => r.eventName === "INSERT").flatMap(targetsOfRecord);
235
+ return await Stdlib_Array.reduce(targets, Promise.resolve(), (acc, param) => {
236
+ let key = param.key;
237
+ let bucket = param.bucket;
238
+ return acc.then(() => claim(bucket, key));
239
+ });
240
+ }
241
+
242
+ let S3;
243
+
244
+ export {
245
+ S3,
246
+ getEnv,
247
+ parseEnvObject,
248
+ decodeStore,
249
+ stores,
250
+ decodeRefField,
251
+ decodeEventFields,
252
+ refFieldsByTable,
253
+ tableNameFromEventSourceArn,
254
+ refsOfField,
255
+ keyOfRef,
256
+ resolveTarget,
257
+ claim,
258
+ targetsOfRecord,
259
+ handler,
260
+ }
261
+ /* stores Not a pure module */
@@ -0,0 +1,41 @@
1
+ // The tag that makes "nobody has committed a reference to this object yet"
2
+ // visible to S3 itself.
3
+ //
4
+ // One definition, three readers, and they must agree exactly or the mechanism
5
+ // deletes live data:
6
+ // - the mint side writes it on the presigned PUT (`Upload_Presign_S3_Ops`),
7
+ // - the claim side removes it when a committed event carries the ref
8
+ // (`Upload_Claim_S3_Ops`),
9
+ // - the bucket's lifecycle rule expires what still carries it
10
+ // (`Capability_ObjectStore_S3`).
11
+ // A rule filtered on a key the mint side never writes matches *nothing*, which
12
+ // fails safe; a claim side that strips a different key leaves every object
13
+ // tagged, which does not. Hence one module rather than three string literals.
14
+ //
15
+ // Runtime-pure and Pulumi-free on purpose: two of the three readers are Lambda
16
+ // handlers shipped as EntryPoint modules, so a deploy-time import here would
17
+ // leak `@pulumi/pulumi` into their cold-start graph.
18
+ //
19
+ // The tag's meaning is deliberately "not yet claimed", never "safe to delete".
20
+ // Nothing reads it as permission; the lifecycle rule adds an age condition of
21
+ // its own, and it is opt-in per store.
22
+
23
+ /** Tag key. `:` is a legal S3 tag-key character, and the `reventless:` prefix
24
+ keeps the framework's tags from colliding with a deployment's own. */
25
+ let key = "reventless:pending"
26
+
27
+ /** Tag value. A single-valued tag: the key's presence is the fact, and the
28
+ value exists only because S3 tags are pairs. */
29
+ let value = "true"
30
+
31
+ /**
32
+ The `Tagging` parameter of a `PutObject`, in the URL-encoded query-string form
33
+ S3 specifies for that header.
34
+
35
+ The colon is percent-encoded here because the value is parsed as a query string
36
+ by S3, so its key/value separators are the only characters that may appear raw.
37
+ The SDK hoists this into the presigned URL's query string (verified against a
38
+ live bucket: a PUT that sends nothing but `Content-Type` still lands the tag),
39
+ so the caller needs no cooperation and cannot opt out.
40
+ */
41
+ let putObjectTagging = `${key->encodeURIComponent}=${value->encodeURIComponent}`
@@ -0,0 +1,15 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let key = "reventless:pending";
5
+
6
+ let value = "true";
7
+
8
+ let putObjectTagging = encodeURIComponent(key) + `=` + encodeURIComponent(value);
9
+
10
+ export {
11
+ key,
12
+ value,
13
+ putObjectTagging,
14
+ }
15
+ /* putObjectTagging Not a pure module */
@@ -11,7 +11,7 @@
11
11
  // There is no Function URL and no anonymous surface: authentication is the platform
12
12
  // API's Cognito authorizer, and the verified caller identity reaches the handler in
13
13
  // the resolver payload (see the JS resolver code below). This is route B of
14
- // [docs/plans/upload-release-path.md]; the anonymous Function URL and the unverified
14
+ // [docs/plans/done/upload-release-path.md]; the anonymous Function URL and the unverified
15
15
  // `decodeJwtSub` the mint side used to carry are gone.
16
16
  //
17
17
  // Why an EntryPoint and not a Pulumi `CallbackFunction`: the handler needs the AWS
@@ -106,6 +106,11 @@ let make = (
106
106
  // `GetObject` backs the `HeadObject` the age check reads. One Lambda covers every
107
107
  // declared store (route B1) — but its reach is still the union of those prefixes,
108
108
  // not a wildcard, so it cannot touch a key outside a declared store.
109
+ //
110
+ // `PutObjectTagging` is what lets the presigned PUT carry the pending tag: a
111
+ // presigned request runs with the *signer's* permissions, so tag-on-put fails
112
+ // with AccessDenied without it — and an untagged object is one the sweep can
113
+ // never reach.
109
114
  let _policy =
110
115
  resolvedStores->Pulumi.Output.apply(list => {
111
116
  let arns = list->Array.map(((_, bucket, prefix)) => `arn:aws:s3:::${bucket}/${prefix}/*`)
@@ -124,7 +129,12 @@ let make = (
124
129
  {
125
130
  sid: "AllowUploadObjectAccess",
126
131
  effect: Allow,
127
- actions: Actions(["s3:PutObject", "s3:DeleteObject", "s3:GetObject"]),
132
+ actions: Actions([
133
+ "s3:PutObject",
134
+ "s3:PutObjectTagging",
135
+ "s3:DeleteObject",
136
+ "s3:GetObject",
137
+ ]),
128
138
  resources: Resources(arns),
129
139
  },
130
140
  ],
@@ -64,6 +64,7 @@ function make(api, stores, releaseWindowSecondsOpt, nameOpt, opts) {
64
64
  Effect: "Allow",
65
65
  Action: [
66
66
  "s3:PutObject",
67
+ "s3:PutObjectTagging",
67
68
  "s3:DeleteObject",
68
69
  "s3:GetObject"
69
70
  ],
@@ -25,6 +25,7 @@ type putObjectInput = {
25
25
  @as("Bucket") bucket: string,
26
26
  @as("Key") key: string,
27
27
  @as("ContentType") contentType?: string,
28
+ @as("Tagging") tagging?: string,
28
29
  }
29
30
  type putObjectCommand
30
31
 
@@ -123,6 +124,10 @@ type releaseOutcome = Released | Refused(string)
123
124
  // Key must sit under this store's served prefix (`not_in_store`) and under the
124
125
  // caller's own identity segment within it (`not_yours`); an empty `sub` is
125
126
  // `unauthenticated` (the authorizer should have refused first — this is a guard).
127
+ //
128
+ // One prefix, not a set: changing a store's prefix strands the refs already
129
+ // minted under the old one, so a prefix change is a breaking change resolved by
130
+ // emptying the store and re-seeding, never by carrying both prefixes forever.
126
131
  let scopeCheck = (~key: string, ~sub: string, ~servedPrefix: string): result<unit, string> =>
127
132
  if sub == "" {
128
133
  Error("unauthenticated")
@@ -221,7 +226,18 @@ let handlePresign = async (
221
226
  // served prefix so the CloudFront `{prefix}/*` behavior fronts it, and namespaced
222
227
  // by the verified `sub` so the release rule can tell one caller's objects apart.
223
228
  let key = `${servedPrefix}/${sub}/${NodeCrypto.randomUUID()}/${fileName}`
224
- let command = makePutObjectCommand({bucket, key, contentType: ?args.contentType})
229
+ // The object is provisional from the moment the bytes land: it carries the
230
+ // pending tag until a committed event referencing it strips it (see
231
+ // `Upload_Claim_S3_Ops`). Written into the signature here, not asked of the
232
+ // caller — the SDK hoists `x-amz-tagging` into the presigned URL's query
233
+ // string, so a client that PUTs exactly as it always has still gets tagged
234
+ // bytes, and cannot opt out.
235
+ let command = makePutObjectCommand({
236
+ bucket,
237
+ key,
238
+ contentType: ?args.contentType,
239
+ tagging: Upload_PendingTag.putObjectTagging,
240
+ })
225
241
  let uploadUrl = await getSignedUrl(client, command, {expiresIn: 300})
226
242
  // Same-origin relative ref `/{key}`: the served bucket is fronted read-only by the
227
243
  // UI's own CloudFront distribution under `{prefix}/*`, so a command stores this
@@ -10,6 +10,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
10
10
  import * as ClientS3 from "@aws-sdk/client-s3";
11
11
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
12
12
  import * as S3RequestPresigner from "@aws-sdk/s3-request-presigner";
13
+ import * as Upload_PendingTag$ReventlessAws from "./Upload_PendingTag.res.mjs";
13
14
 
14
15
  function getEnv(k) {
15
16
  let v = process.env[k];
@@ -175,7 +176,8 @@ async function handlePresign(client, bucket, servedPrefix, sub, args) {
175
176
  let command = new ClientS3.PutObjectCommand({
176
177
  Bucket: bucket,
177
178
  Key: key,
178
- ContentType: args.contentType
179
+ ContentType: args.contentType,
180
+ Tagging: Upload_PendingTag$ReventlessAws.putObjectTagging
179
181
  });
180
182
  let uploadUrl = await S3RequestPresigner.getSignedUrl(client, command, {
181
183
  expiresIn: 300
@@ -37,6 +37,14 @@
37
37
 
38
38
  open PulumiAws
39
39
 
40
+ /** One prefix in this bucket whose never-claimed uploads expire, and after how
41
+ many days. A list rather than a scalar because a shared-layout bucket holds
42
+ several stores, and expiry is opt-in per store. */
43
+ type pendingExpiry = {
44
+ prefix: string,
45
+ days: int,
46
+ }
47
+
40
48
  // Browser direct-PUT needs CORS. These defaults match what deployments wrote by
41
49
  // hand; `~corsRules` overrides them for a store the browser never touches.
42
50
  let defaultCorsRules: S3.Bucket.corsRules = [
@@ -64,13 +72,19 @@ let defaultCorsRules: S3.Bucket.corsRules = [
64
72
  `~protect` (default on) blocks `pulumi destroy` and accidental replacement.
65
73
  `~forceDestroy` is its counterpart for disposable stacks: a protected bucket
66
74
  cannot be torn down, so a PR stack would leak exactly the buckets that
67
- sharing exists to save. */
75
+ sharing exists to save.
76
+
77
+ `~expirePending` turns on the sweep of never-claimed uploads. One entry per
78
+ prefix rather than one setting for the bucket, because a shared-layout
79
+ bucket holds several stores and the setting is each store's own — see the
80
+ comment on the rule below before setting it. */
68
81
  let make = (
69
82
  ~name: string,
70
83
  ~keyPrefix: string=Upload_Presign_S3.defaultServedPrefix,
71
84
  ~plugin: option<string>=?,
72
85
  ~protect: bool=true,
73
86
  ~forceDestroy: bool=false,
87
+ ~expirePending: array<pendingExpiry>=[],
74
88
  ~corsRules: S3.Bucket.corsRules=defaultCorsRules,
75
89
  ~opts: option<Pulumi.CustomResourceOptions.t>=?,
76
90
  ): ReventlessInfra.Platform.objectStore => {
@@ -92,11 +106,38 @@ let make = (
92
106
  protect,
93
107
  }
94
108
 
109
+ // Expire objects nobody ever claimed — the half of abandonment the release
110
+ // path cannot reach, because the client that would have released them is
111
+ // gone (tab closed, navigation, dead network).
112
+ //
113
+ // Filtered on the pending tag **and** this store's served prefix, never on
114
+ // either alone, and written per store rather than once per bucket — a shared
115
+ // bucket holds several stores, and expiring a neighbouring store's objects on
116
+ // this store's setting would be exactly the wrong kind of surprise on a
117
+ // bucket created with `protect: true`.
118
+ //
119
+ // What makes it safe is what it *cannot* match. An object minted before this
120
+ // mechanism existed carries no tag; a claimed object has had its tag stripped.
121
+ // Neither is inside the filter, so the rule's blast radius is precisely
122
+ // "uploaded here, and no committed event references it". The one way it
123
+ // deletes live data is a claim component that stopped and was not noticed —
124
+ // which is why the claimer alarms on its own lag, why this is opt-in per
125
+ // store, and why the plan sequences reconciliation before the first store
126
+ // turns it on.
127
+ let lifecycleRules = expirePending->Array.map(({prefix, days}) => {
128
+ S3.Bucket.id: `reventless-pending-expiry-${prefix->String.replaceAll("/", "-")}`,
129
+ enabled: true,
130
+ prefix: `${prefix}/`,
131
+ tags: Dict.fromArray([(Upload_PendingTag.key, Upload_PendingTag.value)]),
132
+ expiration: {days: days},
133
+ })
134
+
95
135
  let bucket = S3.Bucket.make(
96
136
  ~name,
97
137
  ~args={
98
138
  corsRules: corsRules->Pulumi.Input.make,
99
139
  forceDestroy: forceDestroy->Pulumi.Input.make,
140
+ lifecycleRules: lifecycleRules->Pulumi.Input.make,
100
141
  tags: AWS.Tags.make(
101
142
  ~name,
102
143
  ~kind,
@@ -3,6 +3,7 @@
3
3
  import * as Aws from "@pulumi/aws";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as AWS_Tags$ReventlessAws from "../adapter/AWS_Tags.res.mjs";
6
+ import * as Upload_PendingTag$ReventlessAws from "../adapter/Upload/Upload_PendingTag.res.mjs";
6
7
  import * as Upload_Presign_S3$ReventlessAws from "../adapter/Upload/Upload_Presign_S3.res.mjs";
7
8
 
8
9
  let defaultCorsRules = [{
@@ -18,10 +19,11 @@ let defaultCorsRules = [{
18
19
  maxAgeSeconds: 3000
19
20
  }];
20
21
 
21
- function make(name, keyPrefixOpt, plugin, protectOpt, forceDestroyOpt, corsRulesOpt, opts) {
22
+ function make(name, keyPrefixOpt, plugin, protectOpt, forceDestroyOpt, expirePendingOpt, corsRulesOpt, opts) {
22
23
  let keyPrefix = keyPrefixOpt !== undefined ? keyPrefixOpt : Upload_Presign_S3$ReventlessAws.defaultServedPrefix;
23
24
  let protect = protectOpt !== undefined ? protectOpt : true;
24
25
  let forceDestroy = forceDestroyOpt !== undefined ? forceDestroyOpt : false;
26
+ let expirePending = expirePendingOpt !== undefined ? expirePendingOpt : [];
25
27
  let corsRules = corsRulesOpt !== undefined ? corsRulesOpt : defaultCorsRules;
26
28
  let match = plugin !== undefined ? [
27
29
  "Plugin",
@@ -32,9 +34,25 @@ function make(name, keyPrefixOpt, plugin, protectOpt, forceDestroyOpt, corsRules
32
34
  ];
33
35
  let newrecord = {...Stdlib_Option.getOr(opts, {})};
34
36
  newrecord.protect = protect;
37
+ let lifecycleRules = expirePending.map(param => {
38
+ let prefix = param.prefix;
39
+ return {
40
+ enabled: true,
41
+ expiration: {
42
+ days: param.days
43
+ },
44
+ id: `reventless-pending-expiry-` + prefix.replaceAll("/", "-"),
45
+ prefix: prefix + `/`,
46
+ tags: Object.fromEntries([[
47
+ Upload_PendingTag$ReventlessAws.key,
48
+ Upload_PendingTag$ReventlessAws.value
49
+ ]])
50
+ };
51
+ });
35
52
  let bucket = new (Aws.s3.Bucket)(name, {
36
53
  corsRules: corsRules,
37
54
  forceDestroy: forceDestroy,
55
+ lifecycleRules: lifecycleRules,
38
56
  tags: AWS_Tags$ReventlessAws.make(name, match[0], {
39
57
  TAG: "Other",
40
58
  _0: "ObjectStore"
@@ -549,6 +549,42 @@ let _makeApiResourceWith = (
549
549
  }
550
550
  let graphQLApi = AppSync.GraphQLApi.make(~name, ~args=apiArgs, ~opts=Some(customOpts))
551
551
 
552
+ // Managed log group with tiered retention for AppSync's own field-resolver logs,
553
+ // which land in `/aws/appsync/apis/<id>` and otherwise live forever with no
554
+ // retention. Managed on every stack by default (bar any in
555
+ // `unmanagedLogGroupStacks`), same as the Lambda groups. The name derives from
556
+ // the API id output so it matches the group AppSync writes to, and Pulumi
557
+ // therefore tears it down with the API.
558
+ let stack = Pulumi.Pulumi.getStackName()
559
+ let prodStacks = Util_HostUiDomain.resolveProdStacks()
560
+ let unmanagedStacks = Util_LogRetention.parseUnmanagedStacks(
561
+ Util_LocalConfig.get("unmanagedLogGroupStacks")->Option.getOr(""),
562
+ )
563
+ if Util_LogRetention.managesLogGroup(~stack, ~unmanagedStacks) {
564
+ let _ = Cloudwatch.LogGroup.make(
565
+ ~name=`${name}AppSyncLogGroup`,
566
+ ~args={
567
+ name: graphQLApi.id
568
+ ->Pulumi.Output.apply(id => `/aws/appsync/apis/${id}`)
569
+ ->Pulumi.Output.asInput,
570
+ retentionInDays: Util_LogRetention.retentionDaysFor(
571
+ ~stack,
572
+ ~prodStacks,
573
+ ~configOverride=?Util_LocalConfig.get("logRetentionDays")->Option.flatMap(s =>
574
+ Int.fromString(s)
575
+ ),
576
+ )->Pulumi.Input.make,
577
+ tags: AWS.Tags.make(
578
+ ~name=`${name}AppSyncLogGroup`,
579
+ ~kind=ReventlessCore.ComponentType.Plugin,
580
+ ~role=Logs,
581
+ ~scope=Plugin,
582
+ ),
583
+ },
584
+ ~opts=customOpts,
585
+ )
586
+ }
587
+
552
588
  (graphQLApi->Pulumi.Output.make, iamRole->Pulumi.Output.make)
553
589
  }
554
590
 
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Effect from "@reventlessdev/rescript-effect/src/Effect.res.mjs";
4
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
5
  import * as Aws from "@pulumi/aws";
5
6
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
6
7
  import * as Nodecrypto from "node:crypto";
@@ -15,7 +16,10 @@ import * as AWS_Tags$ReventlessAws from "../../adapter/AWS_Tags.res.mjs";
15
16
  import * as ClientAppsync from "@aws-sdk/client-appsync";
16
17
  import * as Auth_Cognito$ReventlessAws from "../../adapter/Auth/Auth_Cognito.res.mjs";
17
18
  import * as AppSync_Error$ReventlessAws from "../../errors/AppSync_Error.res.mjs";
19
+ import * as Util_LocalConfig$ReventlessAws from "../../util/Util_LocalConfig.res.mjs";
18
20
  import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
21
+ import * as Util_HostUiDomain$ReventlessAws from "../../util/Util_HostUiDomain.res.mjs";
22
+ import * as Util_LogRetention$ReventlessAws from "../../util/Util_LogRetention.res.mjs";
19
23
  import * as AppSync_SdlDecorate$ReventlessAws from "./AppSync_SdlDecorate.res.mjs";
20
24
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
21
25
 
@@ -308,6 +312,16 @@ function _makeApiResourceWith(name, schema, userPoolConfig, opts) {
308
312
  tags: apiArgs_tags
309
313
  };
310
314
  let graphQLApi = new (Aws.appsync.GraphQLApi)(name, apiArgs, customOpts);
315
+ let stack = Pulumi.getStack();
316
+ let prodStacks = Util_HostUiDomain$ReventlessAws.resolveProdStacks();
317
+ let unmanagedStacks = Util_LogRetention$ReventlessAws.parseUnmanagedStacks(Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("unmanagedLogGroupStacks"), ""));
318
+ if (Util_LogRetention$ReventlessAws.managesLogGroup(stack, unmanagedStacks)) {
319
+ new (Aws.cloudwatch.LogGroup)(name + `AppSyncLogGroup`, {
320
+ name: graphQLApi.id.apply(id => `/aws/appsync/apis/` + id),
321
+ retentionInDays: Util_LogRetention$ReventlessAws.retentionDaysFor(stack, prodStacks, Stdlib_Option.flatMap(Util_LocalConfig$ReventlessAws.get("logRetentionDays"), s => Stdlib_Int.fromString(s, undefined))),
322
+ tags: AWS_Tags$ReventlessAws.make(name + `AppSyncLogGroup`, "Plugin", "Logs", "Plugin", undefined, undefined, undefined, undefined)
323
+ }, customOpts);
324
+ }
311
325
  return [
312
326
  Pulumi.output(graphQLApi),
313
327
  Pulumi.output(iamRole)