@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.254 (2026-08-02)
7
+
8
+ ### Features
9
+
10
+ * **aws,core,doc:** environment-tiered CloudWatch log retention and levels ([8032023](https://github.com/ReventlessDev/reventless-core/commit/803202305a7d352484b8df5e58df03c92ca58b5f))
11
+ * **aws,core,spec,seed-aws:** expire uploads nobody committed a reference to ([f63e84c](https://github.com/ReventlessDev/reventless-core/commit/f63e84c1a11cc350b799a6f69a2e7427cf1ea6e9))
12
+
13
+
6
14
  # 3.0.0-alpha.253 (2026-08-01)
7
15
 
8
16
  * feat(aws,core,spec)!: qualify store prefixes by plugin and refuse name collisions ([da39405](https://github.com/ReventlessDev/reventless-core/commit/da394059d9f8f981bf7adc79e2c1ce2b429e0267))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.253",
3
+ "version": "3.0.0-alpha.254",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -11,18 +11,18 @@
11
11
  "@aws-sdk/s3-request-presigner": "3.970.0",
12
12
  "sury": "11.0.0-alpha.4",
13
13
  "uuid": "^13.0.0",
14
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.0",
14
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.1",
15
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
15
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
16
17
  "@reventlessdev/rescript-node": "2.0.0-alpha.0",
17
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.62",
18
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.63",
19
19
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
20
20
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.200",
22
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.64",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.117",
24
- "@reventlessdev/reventless-spec": "3.0.0-alpha.92",
25
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30"
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.201",
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.118",
23
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.65",
24
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.93"
26
26
  },
27
27
  "devDependencies": {
28
28
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -157,6 +157,58 @@ module MakeWithConfig = (
157
157
  ->Pulumi.Config.get("stack")
158
158
  ->Option.map(stack => Pulumi.StackReference.make(stack))
159
159
 
160
+ // The declared object stores, as the upload claimer needs them: qualified
161
+ // name, physical bucket, served prefix.
162
+ //
163
+ // Stores are provisioned by the *platform* deploy; the claimer is wired into
164
+ // the *plugin* stack, because that is where the event log streams it reads
165
+ // exist. So the two halves meet here — across the stack reference in plugin
166
+ // mode, and through the platform's own in-process record when platform and
167
+ // plugin are one program.
168
+ //
169
+ // Read lazily (a thunk, not a value) for the monolithic case: `deployPlatform`
170
+ // fills `objectStoreEndpointsRef` as it provisions, and this is called during
171
+ // a later plugin build.
172
+ let claimStores = (): Pulumi.Output.t<array<Upload_Claim_S3.storeConfig>> =>
173
+ switch platformStackRef {
174
+ | Some(stackRef) =>
175
+ // The `objectStores` export: `{ "<plugin>.<store>": {bucketName, keyPrefix} }`.
176
+ // A platform that provisions none omits the export entirely, which reads
177
+ // as "no store to claim in" — the claimer then builds nothing, rather
178
+ // than building something that can reach nothing.
179
+ (stackRef->Pulumi.StackReference.getOutput("objectStores"): Pulumi.Output.t<option<JSON.t>>)
180
+ ->Pulumi.Output.apply(objectStores =>
181
+ objectStores
182
+ ->Option.flatMap(JSON.Decode.object)
183
+ ->Option.getOr(Dict.make())
184
+ ->Dict.toArray
185
+ ->Array.filterMap(((qualified, v)) =>
186
+ v
187
+ ->JSON.Decode.object
188
+ ->Option.flatMap(o =>
189
+ switch (
190
+ o->Dict.get("bucketName")->Option.flatMap(JSON.Decode.string),
191
+ o->Dict.get("keyPrefix")->Option.flatMap(JSON.Decode.string),
192
+ ) {
193
+ | (Some(bucketName), Some(servedPrefix)) =>
194
+ Some({Upload_Claim_S3.qualified: qualified, bucketName, servedPrefix})
195
+ | _ => None
196
+ }
197
+ )
198
+ )
199
+ )
200
+ | None =>
201
+ objectStoreEndpointsRef.contents
202
+ ->Array.map(e =>
203
+ e.bucketName->Pulumi.Output.apply(bucketName => {
204
+ Upload_Claim_S3.qualified: e.store,
205
+ bucketName,
206
+ servedPrefix: e.keyPrefix,
207
+ })
208
+ )
209
+ ->Pulumi.Output.all
210
+ }
211
+
160
212
  // ── Merged-mode source SDL assembly (merged-api plan, Phase 3) ────────────
161
213
  // Empty base fragment — no types, no mutations, no queries. Used by the
162
214
  // plugin Api in split mode (so plugin schema has no core fields) and as the
@@ -172,7 +224,7 @@ module MakeWithConfig = (
172
224
  // The upload service (route B) lives on the domain base, not the Admin-gated admin
173
225
  // base: `Upload_Presign`/`Upload_Release` take the domain API's default
174
226
  // `AllowAuthenticated` auth so any authenticated user can mint/release their own
175
- // uploads. See [docs/plans/upload-release-path.md] § "Which API". (Split mode only —
227
+ // uploads. See [docs/plans/done/upload-release-path.md] § "Which API". (Split mode only —
176
228
  // unified mode carries the admin base here instead; it declares no stores in practice,
177
229
  // so the upload resolvers below are never created there.)
178
230
  let domainBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
@@ -763,7 +815,7 @@ module MakeWithConfig = (
763
815
  // Only active when the Events API resource exists (platform/monolithic mode).
764
816
  subscriptionInfraHook: ?domainEventsApiOpt->Option.map(eventsApi =>
765
817
  (params: ReventlessCore.Plugin_Helpers.subscriptionInfraParams) => {
766
- let {allQueryDbs, allEventTopics, eventLogEntries, opts} = params
818
+ let {pluginName, allQueryDbs, allEventTopics, eventLogEntries, opts} = params
767
819
  let customOpts =
768
820
  opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
769
821
  // StateTopic Lambda per stream-enabled QueryDb — publishes row changes
@@ -799,19 +851,34 @@ module MakeWithConfig = (
799
851
  let isSns =
800
852
  EventTopicPublisher_SNS.snsRegistry->Set.has(entry.displayName) ||
801
853
  EventTopicPublisher_SNS.snsRegistry->Set.has(entry.busKey)
802
- let eventTopicOutputs =
803
- isSns
804
- ? allEventTopics
805
- ->Dict.get(entry.displayName)
806
- ->Option.orElse(allEventTopics->Dict.get(entry.busKey))
807
- : None
808
- eventTopicOutputs->Option.forEach(outputs =>
809
- EventLogSubscription_AppSync.make(
810
- ~name=entry.displayName,
811
- ~topicName=entry.displayName,
854
+ let topicOutputs =
855
+ allEventTopics
856
+ ->Dict.get(entry.displayName)
857
+ ->Option.orElse(allEventTopics->Dict.get(entry.busKey))
858
+ isSns
859
+ ? topicOutputs->Option.forEach(outputs =>
860
+ EventLogSubscription_AppSync.make(
861
+ ~name=entry.displayName,
862
+ ~topicName=entry.displayName,
863
+ ~eventTopicOutputs=outputs,
864
+ ~eventsApi,
865
+ ~opts=customOpts,
866
+ )
867
+ )
868
+ : ()
869
+ // Register this event log with the upload claimer if — and only if —
870
+ // its events declare a `@storageRef` field. An event log that declares
871
+ // none is not registered, so a platform whose plugins declare no store
872
+ // provisions no claimer at all. `isSns` is the same synchronous answer
873
+ // the subscription above turns on, so the two cannot disagree about
874
+ // which channel an event log publishes through.
875
+ topicOutputs->Option.forEach(outputs =>
876
+ Upload_Claim_S3.make(
877
+ ~plugin=pluginName,
878
+ ~eventLogName=entry.displayName,
879
+ ~eventSchema=entry.eventSchema,
812
880
  ~eventTopicOutputs=outputs,
813
- ~eventsApi,
814
- ~opts=customOpts,
881
+ ~isStreamBacked=!isSns,
815
882
  )
816
883
  )
817
884
  })
@@ -822,6 +889,9 @@ module MakeWithConfig = (
822
889
  // empty because the hook hasn't run yet. Admin's hook fires synchronously,
823
890
  // but the call site is unified here so admin and plugins share one path.
824
891
  StateTopic_AppSync.finish(~eventsApi, ~opts={})
892
+ // Same timing constraint, same reason: the claimer's registry is filled
893
+ // by the loop just above and drained here.
894
+ Upload_Claim_S3.finish(~plugin=pluginName, ~stores=claimStores, ~opts=customOpts)
825
895
  }
826
896
  ),
827
897
  }
@@ -1063,12 +1133,15 @@ module MakeWithConfig = (
1063
1133
  // omitted (byte-identical config.json).
1064
1134
  geocoderPlaceIndex?: ReventlessInfra.Platform.geocoderIndex,
1065
1135
  // Optional object store for direct-to-S3 uploads, as returned by
1066
- // `Capability_ObjectStore_S3.make`. When set, the deploy provisions the
1067
- // presign service (Upload_Presign_S3) against it, threads that service's URL
1068
- // into config.json as `uploadEndpoint`, and fronts the store read-only on
1069
- // the host-shell CloudFront distribution under the presign service's own
1070
- // prefix (a `{prefix}/*` ordered cache behavior + origin + scoped
1071
- // BucketPolicy, private, OAC-read).
1136
+ // `Capability_ObjectStore_S3.make`. When set, the deploy registers it with
1137
+ // the platform's one Upload service (`Upload_Presign_S3`) under the `uploads`
1138
+ // store key, and fronts the store read-only on the host-shell CloudFront
1139
+ // distribution under that prefix (a `{prefix}/*` ordered cache behavior +
1140
+ // origin + scoped BucketPolicy, private, OAC-read).
1141
+ //
1142
+ // No endpoint is written into config.json: under route B a caller mints
1143
+ // through the domain API's `Upload_Presign` mutation, naming the store, so
1144
+ // there is no per-store URL to publish or to resolve.
1072
1145
  //
1073
1146
  // One field replaces the former `enableUploads` / `uploadBucketName` /
1074
1147
  // `servedBuckets` trio, which were three ways of saying one thing and left
@@ -1584,6 +1657,46 @@ module MakeWithConfig = (
1584
1657
  // policy and one CDN origin however many stores live in it.
1585
1658
  let storeBuckets: Dict.t<ReventlessInfra.Platform.objectStore> = Dict.make()
1586
1659
  let storeBucketOrder: array<string> = []
1660
+
1661
+ // Which stores expire their never-claimed uploads, grouped by the bucket
1662
+ // they live in. Computed before any bucket is created, because a bucket
1663
+ // carries its lifecycle rules from birth and a shared-layout bucket is
1664
+ // created once for several stores — the first store through the loop below
1665
+ // would otherwise be the only one whose setting was ever read.
1666
+ //
1667
+ // Off unless a deployment names the store. See `Util_StoreLayout.pendingExpiryFor`
1668
+ // for why this is a deployment's setting and not a declaration's, and why
1669
+ // there is deliberately no default.
1670
+ let pendingExpiryConfig = Util_LocalConfig.get("pendingUploadExpiryDays")
1671
+ let pendingExpiryByBucket: Dict.t<array<Capability_ObjectStore_S3.pendingExpiry>> = Dict.make()
1672
+ declaredStores->Array.forEach(((plugin, store)) =>
1673
+ Util_StoreLayout.pendingExpiryFor(
1674
+ ~config=pendingExpiryConfig,
1675
+ ~store=`${plugin}.${store}`,
1676
+ )->Option.forEach(days => {
1677
+ let bucketName = Util_StoreLayout.bucketNameFor(
1678
+ ~layout=storeLayout,
1679
+ ~stack=stackName,
1680
+ ~plugin,
1681
+ ~store,
1682
+ )
1683
+ let prefix = Util_StoreLayout.keyPrefixFor(~plugin, ~store)
1684
+ pendingExpiryByBucket->Dict.set(
1685
+ bucketName,
1686
+ pendingExpiryByBucket
1687
+ ->Dict.get(bucketName)
1688
+ ->Option.getOr([])
1689
+ ->Array.concat([{Capability_ObjectStore_S3.prefix, days}]),
1690
+ )
1691
+ log.info(
1692
+ ~comp="Platform:deployPlatform",
1693
+ `object store ${plugin}.${store}: never-claimed uploads under ${prefix}/ expire after ` ++
1694
+ `${days->Int.toString} days. Claimed objects and objects minted before the claim ` ++
1695
+ `component existed carry no pending tag and are outside the rule.`,
1696
+ )
1697
+ })
1698
+ )
1699
+
1587
1700
  let declaredStoreServices = declaredStores->Array.map(((plugin, store)) => {
1588
1701
  let bucketName = Util_StoreLayout.bucketNameFor(
1589
1702
  ~layout=storeLayout,
@@ -1608,6 +1721,7 @@ module MakeWithConfig = (
1608
1721
  // disposable stack would leak exactly the buckets sharing exists to
1609
1722
  // save. The two settings are opposite faces of one decision.
1610
1723
  ~forceDestroy=storeProtection == Unprotected,
1724
+ ~expirePending=pendingExpiryByBucket->Dict.get(bucketName)->Option.getOr([]),
1611
1725
  )
1612
1726
  storeBuckets->Dict.set(bucketName, b)
1613
1727
  storeBucketOrder->Array.push(bucketName)
@@ -35,6 +35,7 @@ import * as EventLogBackend$ReventlessAws from "./adapter/EventLog/EventLogBacke
35
35
  import * as Platform_Admin$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin.res.mjs";
36
36
  import * as PluginBehavior$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginBehavior.res.mjs";
37
37
  import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
38
+ import * as Upload_Claim_S3$ReventlessAws from "./adapter/Upload/Upload_Claim_S3.res.mjs";
38
39
  import * as PgProjectionFeed$ReventlessAws from "./adapter/Postgres/PgProjectionFeed.res.mjs";
39
40
  import * as Util_LocalConfig$ReventlessAws from "./util/Util_LocalConfig.res.mjs";
40
41
  import * as Util_StoreLayout$ReventlessAws from "./util/Util_StoreLayout.res.mjs";
@@ -156,6 +157,30 @@ function MakeWithConfig(Config) {
156
157
  contents: "Domain"
157
158
  };
158
159
  let platformStackRef = Stdlib_Option.map(new Pulumi.Config("platform").get("stack"), stack => new Pulumi.StackReference(stack));
160
+ let claimStores = () => {
161
+ if (platformStackRef !== undefined) {
162
+ return Primitive_option.valFromOption(platformStackRef).getOutput("objectStores").apply(objectStores => Stdlib_Array.filterMap(Object.entries(Stdlib_Option.getOr(Stdlib_Option.flatMap(objectStores, Stdlib_JSON.Decode.object), {})), param => {
163
+ let qualified = param[0];
164
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(param[1]), o => {
165
+ let match = Stdlib_Option.flatMap(o["bucketName"], Stdlib_JSON.Decode.string);
166
+ let match$1 = Stdlib_Option.flatMap(o["keyPrefix"], Stdlib_JSON.Decode.string);
167
+ if (match !== undefined && match$1 !== undefined) {
168
+ return {
169
+ qualified: qualified,
170
+ bucketName: match,
171
+ servedPrefix: match$1
172
+ };
173
+ }
174
+ });
175
+ }));
176
+ } else {
177
+ return Pulumi.all(objectStoreEndpointsRef.contents.map(e => e.bucketName.apply(bucketName => ({
178
+ qualified: e.store,
179
+ bucketName: bucketName,
180
+ servedPrefix: e.keyPrefix
181
+ }))));
182
+ }
183
+ };
159
184
  let domainBaseFragment = GraphQL_Stitcher$ReventlessCore.encode({
160
185
  types: Platform_AdminApi$ReventlessCore.uploadTypes,
161
186
  mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields,
@@ -457,6 +482,7 @@ function MakeWithConfig(Config) {
457
482
  let hooks_subscriptionInfraHook = Stdlib_Option.map(domainEventsApiOpt, eventsApi => (params => {
458
483
  let allEventTopics = params.allEventTopics;
459
484
  let allQueryDbs = params.allQueryDbs;
485
+ let pluginName = params.pluginName;
460
486
  let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(params.opts);
461
487
  Stdlib_Dict.forEachWithKey(allQueryDbs, (_queryDbOutputs, readModelName) => {
462
488
  if (!QueryDbStorage_DynamoDbStream$ReventlessAws.streamRegistry.has(readModelName)) {
@@ -467,10 +493,14 @@ function MakeWithConfig(Config) {
467
493
  });
468
494
  params.eventLogEntries.forEach(entry => {
469
495
  let isSns = EventTopicPublisher_SNS$ReventlessAws.snsRegistry.has(entry.displayName) || EventTopicPublisher_SNS$ReventlessAws.snsRegistry.has(entry.busKey);
470
- let eventTopicOutputs = isSns ? Stdlib_Option.orElse(allEventTopics[entry.displayName], allEventTopics[entry.busKey]) : undefined;
471
- Stdlib_Option.forEach(eventTopicOutputs, outputs => EventLogSubscription_AppSync$ReventlessAws.make(entry.displayName, entry.displayName, outputs, eventsApi, customOpts));
496
+ let topicOutputs = Stdlib_Option.orElse(allEventTopics[entry.displayName], allEventTopics[entry.busKey]);
497
+ if (isSns) {
498
+ Stdlib_Option.forEach(topicOutputs, outputs => EventLogSubscription_AppSync$ReventlessAws.make(entry.displayName, entry.displayName, outputs, eventsApi, customOpts));
499
+ }
500
+ Stdlib_Option.forEach(topicOutputs, outputs => Upload_Claim_S3$ReventlessAws.make(pluginName, entry.displayName, entry.eventSchema, outputs, !isSns));
472
501
  });
473
502
  StateTopic_AppSync$ReventlessAws.finish(eventsApi, {});
503
+ Upload_Claim_S3$ReventlessAws.finish(pluginName, claimStores, undefined, customOpts);
474
504
  }));
475
505
  let hooks_preResolversSchemaHook = (name, version, pluginFragment) => {
476
506
  let sdl = AppSync_Adapter$ReventlessAws.stitchStandaloneWithAwsDirectives(pluginFragment);
@@ -925,6 +955,21 @@ function MakeWithConfig(Config) {
925
955
  }
926
956
  let storeBuckets = {};
927
957
  let storeBucketOrder = [];
958
+ let pendingExpiryConfig = Util_LocalConfig$ReventlessAws.get("pendingUploadExpiryDays");
959
+ let pendingExpiryByBucket = {};
960
+ declaredStores.forEach(param => {
961
+ let store = param[1];
962
+ let plugin = param[0];
963
+ Stdlib_Option.forEach(Util_StoreLayout$ReventlessAws.pendingExpiryFor(pendingExpiryConfig, plugin + `.` + store), days => {
964
+ let bucketName = Util_StoreLayout$ReventlessAws.bucketNameFor(storeLayout, stackName, plugin, store);
965
+ let prefix = Util_StoreLayout$ReventlessAws.keyPrefixFor(plugin, store);
966
+ pendingExpiryByBucket[bucketName] = Stdlib_Option.getOr(pendingExpiryByBucket[bucketName], []).concat([{
967
+ prefix: prefix,
968
+ days: days
969
+ }]);
970
+ log.info("Platform:deployPlatform", undefined, `object store ` + plugin + `.` + store + `: never-claimed uploads under ` + prefix + `/ expire after ` + (days.toString() + ` days. Claimed objects and objects minted before the claim `) + `component existed carry no pending tag and are outside the rule.`);
971
+ });
972
+ });
928
973
  let declaredStoreServices = declaredStores.map(param => {
929
974
  let store = param[1];
930
975
  let plugin = param[0];
@@ -937,7 +982,7 @@ function MakeWithConfig(Config) {
937
982
  } else {
938
983
  let tmp;
939
984
  tmp = storeLayout === "PerStore" ? plugin : undefined;
940
- let b$1 = Capability_ObjectStore_S3$ReventlessAws.make(bucketName, keyPrefix, tmp, storeProtection === "Protected", storeProtection === "Unprotected", undefined, undefined);
985
+ let b$1 = Capability_ObjectStore_S3$ReventlessAws.make(bucketName, keyPrefix, tmp, storeProtection === "Protected", storeProtection === "Unprotected", Stdlib_Option.getOr(pendingExpiryByBucket[bucketName], []), undefined, undefined);
941
986
  storeBuckets[bucketName] = b$1;
942
987
  storeBucketOrder.push(bucketName);
943
988
  bucket = b$1;
@@ -1324,6 +1369,30 @@ function Make($star) {
1324
1369
  contents: "Domain"
1325
1370
  };
1326
1371
  let platformStackRef = Stdlib_Option.map(new Pulumi.Config("platform").get("stack"), stack => new Pulumi.StackReference(stack));
1372
+ let claimStores = () => {
1373
+ if (platformStackRef !== undefined) {
1374
+ return Primitive_option.valFromOption(platformStackRef).getOutput("objectStores").apply(objectStores => Stdlib_Array.filterMap(Object.entries(Stdlib_Option.getOr(Stdlib_Option.flatMap(objectStores, Stdlib_JSON.Decode.object), {})), param => {
1375
+ let qualified = param[0];
1376
+ return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(param[1]), o => {
1377
+ let match = Stdlib_Option.flatMap(o["bucketName"], Stdlib_JSON.Decode.string);
1378
+ let match$1 = Stdlib_Option.flatMap(o["keyPrefix"], Stdlib_JSON.Decode.string);
1379
+ if (match !== undefined && match$1 !== undefined) {
1380
+ return {
1381
+ qualified: qualified,
1382
+ bucketName: match,
1383
+ servedPrefix: match$1
1384
+ };
1385
+ }
1386
+ });
1387
+ }));
1388
+ } else {
1389
+ return Pulumi.all(objectStoreEndpointsRef.contents.map(e => e.bucketName.apply(bucketName => ({
1390
+ qualified: e.store,
1391
+ bucketName: bucketName,
1392
+ servedPrefix: e.keyPrefix
1393
+ }))));
1394
+ }
1395
+ };
1327
1396
  let domainBaseFragment = GraphQL_Stitcher$ReventlessCore.encode({
1328
1397
  types: Platform_AdminApi$ReventlessCore.uploadTypes,
1329
1398
  mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields,
@@ -1625,6 +1694,7 @@ function Make($star) {
1625
1694
  let hooks_subscriptionInfraHook = Stdlib_Option.map(domainEventsApiOpt, eventsApi => (params => {
1626
1695
  let allEventTopics = params.allEventTopics;
1627
1696
  let allQueryDbs = params.allQueryDbs;
1697
+ let pluginName = params.pluginName;
1628
1698
  let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(params.opts);
1629
1699
  Stdlib_Dict.forEachWithKey(allQueryDbs, (_queryDbOutputs, readModelName) => {
1630
1700
  if (!QueryDbStorage_DynamoDbStream$ReventlessAws.streamRegistry.has(readModelName)) {
@@ -1635,10 +1705,14 @@ function Make($star) {
1635
1705
  });
1636
1706
  params.eventLogEntries.forEach(entry => {
1637
1707
  let isSns = EventTopicPublisher_SNS$ReventlessAws.snsRegistry.has(entry.displayName) || EventTopicPublisher_SNS$ReventlessAws.snsRegistry.has(entry.busKey);
1638
- let eventTopicOutputs = isSns ? Stdlib_Option.orElse(allEventTopics[entry.displayName], allEventTopics[entry.busKey]) : undefined;
1639
- Stdlib_Option.forEach(eventTopicOutputs, outputs => EventLogSubscription_AppSync$ReventlessAws.make(entry.displayName, entry.displayName, outputs, eventsApi, customOpts));
1708
+ let topicOutputs = Stdlib_Option.orElse(allEventTopics[entry.displayName], allEventTopics[entry.busKey]);
1709
+ if (isSns) {
1710
+ Stdlib_Option.forEach(topicOutputs, outputs => EventLogSubscription_AppSync$ReventlessAws.make(entry.displayName, entry.displayName, outputs, eventsApi, customOpts));
1711
+ }
1712
+ Stdlib_Option.forEach(topicOutputs, outputs => Upload_Claim_S3$ReventlessAws.make(pluginName, entry.displayName, entry.eventSchema, outputs, !isSns));
1640
1713
  });
1641
1714
  StateTopic_AppSync$ReventlessAws.finish(eventsApi, {});
1715
+ Upload_Claim_S3$ReventlessAws.finish(pluginName, claimStores, undefined, customOpts);
1642
1716
  }));
1643
1717
  let hooks_preResolversSchemaHook = (name, version, pluginFragment) => {
1644
1718
  let sdl = AppSync_Adapter$ReventlessAws.stitchStandaloneWithAwsDirectives(pluginFragment);
@@ -2070,6 +2144,21 @@ function Make($star) {
2070
2144
  }
2071
2145
  let storeBuckets = {};
2072
2146
  let storeBucketOrder = [];
2147
+ let pendingExpiryConfig = Util_LocalConfig$ReventlessAws.get("pendingUploadExpiryDays");
2148
+ let pendingExpiryByBucket = {};
2149
+ declaredStores.forEach(param => {
2150
+ let store = param[1];
2151
+ let plugin = param[0];
2152
+ Stdlib_Option.forEach(Util_StoreLayout$ReventlessAws.pendingExpiryFor(pendingExpiryConfig, plugin + `.` + store), days => {
2153
+ let bucketName = Util_StoreLayout$ReventlessAws.bucketNameFor(storeLayout, stackName, plugin, store);
2154
+ let prefix = Util_StoreLayout$ReventlessAws.keyPrefixFor(plugin, store);
2155
+ pendingExpiryByBucket[bucketName] = Stdlib_Option.getOr(pendingExpiryByBucket[bucketName], []).concat([{
2156
+ prefix: prefix,
2157
+ days: days
2158
+ }]);
2159
+ log.info("Platform:deployPlatform", undefined, `object store ` + plugin + `.` + store + `: never-claimed uploads under ` + prefix + `/ expire after ` + (days.toString() + ` days. Claimed objects and objects minted before the claim `) + `component existed carry no pending tag and are outside the rule.`);
2160
+ });
2161
+ });
2073
2162
  let declaredStoreServices = declaredStores.map(param => {
2074
2163
  let store = param[1];
2075
2164
  let plugin = param[0];
@@ -2082,7 +2171,7 @@ function Make($star) {
2082
2171
  } else {
2083
2172
  let tmp;
2084
2173
  tmp = storeLayout === "PerStore" ? plugin : undefined;
2085
- let b$1 = Capability_ObjectStore_S3$ReventlessAws.make(bucketName, keyPrefix, tmp, storeProtection === "Protected", storeProtection === "Unprotected", undefined, undefined);
2174
+ let b$1 = Capability_ObjectStore_S3$ReventlessAws.make(bucketName, keyPrefix, tmp, storeProtection === "Protected", storeProtection === "Unprotected", Stdlib_Option.getOr(pendingExpiryByBucket[bucketName], []), undefined, undefined);
2086
2175
  storeBuckets[bucketName] = b$1;
2087
2176
  storeBucketOrder.push(bucketName);
2088
2177
  bucket = b$1;
@@ -298,6 +298,11 @@ let finish = () =>
298
298
  }
299
299
  })
300
300
  )
301
+ // First-class log-level override → LOG_LEVEL, winning over any generic
302
+ // envVars entry. When unset, makeFromCodeAsset applies the tier default.
303
+ cfg.logLevel->Option.forEach(level =>
304
+ envVars->Dict.set("LOG_LEVEL", level->Pulumi.Output.make->Pulumi.Output.asInput)
305
+ )
301
306
 
302
307
  // Build AssetArchive: static re-export + user packages
303
308
  let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
@@ -197,6 +197,9 @@ function finish() {
197
197
  return;
198
198
  }
199
199
  }));
200
+ Stdlib_Option.forEach(cfg.logLevel, level => {
201
+ envVars["LOG_LEVEL"] = Pulumi.output(level);
202
+ });
200
203
  let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/AggregateEntryPoint.mjs", packageDirs, undefined);
201
204
  Stdlib_Option.forEach(cfg.sqsBatchSize, CommandTopicChannel_SQS_Sync$ReventlessAws.setBatchSize);
202
205
  let vpcConfig = pgSelection !== undefined ? pgSelection.securityGroupId.apply(sgId => ({
@@ -276,6 +276,11 @@ let finish = () =>
276
276
  }
277
277
  })
278
278
  )
279
+ // First-class log-level override → LOG_LEVEL, winning over any generic
280
+ // envVars entry. When unset, makeFromCodeAsset applies the tier default.
281
+ cfg.logLevel->Option.forEach(level =>
282
+ envVars->Dict.set("LOG_LEVEL", level->Pulumi.Output.make->Pulumi.Output.asInput)
283
+ )
279
284
 
280
285
  // Build AssetArchive: static re-export + user packages
281
286
  let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
@@ -186,6 +186,9 @@ function finish() {
186
186
  return;
187
187
  }
188
188
  }));
189
+ Stdlib_Option.forEach(cfg.logLevel, level => {
190
+ envVars["LOG_LEVEL"] = Pulumi.output(level);
191
+ });
189
192
  let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/AggregateEntryPoint.mjs", packageDirs, undefined);
190
193
  Stdlib_Option.forEach(cfg.sqsBatchSize, CommandTopicChannel_SQS_Async$ReventlessAws.setBatchSize);
191
194
  let vpcConfig = pgSelection !== undefined ? pgSelection.securityGroupId.apply(sgId => ({
@@ -115,7 +115,9 @@ let makeFromCodeAsset: (
115
115
  ~logRetentionDays: int=?,
116
116
  /** Provision the DCB append-retry/conflict CloudWatch metric filters on this
117
117
  Lambda's log group (command-handler Lambdas only). Requires a managed log
118
- group, so it only takes effect when `~logRetentionDays` is also set. */
118
+ group, so it only takes effect when one is created — i.e. when
119
+ `~logRetentionDays` is set or the stack tier manages its groups
120
+ (`Util_LogRetention.managesLogGroup`). */
119
121
  ~dcbMetrics: bool=?,
120
122
  /** Place the Lambda in a VPC (needed to reach RDS/managed Postgres). When set,
121
123
  the execution role also gets the EC2 network-interface permissions AWS
@@ -143,6 +145,18 @@ let makeFromCodeAsset: (
143
145
  let opts =
144
146
  opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
145
147
 
148
+ // The environment tier drives retention, default log level and whether the log
149
+ // group is Pulumi-managed. Resolved once from the stack name and the shared
150
+ // prod allow-list (the same notion of "production" that names the host-shell
151
+ // domain and the store layout).
152
+ let stack = Pulumi.Pulumi.getStackName()
153
+ let prodStacks = Util_HostUiDomain.resolveProdStacks()
154
+ // Stacks explicitly held on auto-created log groups pending a `pulumi import`
155
+ // (none today — the escape hatch for adopting a pre-existing stack later).
156
+ let unmanagedStacks = Util_LogRetention.parseUnmanagedStacks(
157
+ Util_LocalConfig.get("unmanagedLogGroupStacks")->Option.getOr(""),
158
+ )
159
+
146
160
  // One attribution identity for every resource provisioned here: same owning
147
161
  // component and kind throughout, distinct piece roles — the function itself,
148
162
  // the execution identity it assumes, and its log group are three pieces of one
@@ -219,9 +233,7 @@ let makeFromCodeAsset: (
219
233
  ->Option.getOr([])
220
234
  ->Pulumi.Input.make
221
235
 
222
- let variables = Dict.fromArray([
223
- ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
224
- ])
236
+ let variables = Dict.fromArray([("Environment", stack->Pulumi.Input.make)])
225
237
  envVars->Dict.forEachWithKey((value, key) => {
226
238
  variables->Dict.set(key, value)
227
239
  })
@@ -229,6 +241,21 @@ let makeFromCodeAsset: (
229
241
  variables->Dict.set(key, value)
230
242
  })
231
243
 
244
+ // Default the logger's minimum level per environment tier when nothing already
245
+ // pinned one (caller `envVars` or a consumer-registered `additionalEnvVars`
246
+ // win). Verbose on disposable dev stacks, quiet on prod/beta — for every
247
+ // Lambda, not just the command handlers.
248
+ if variables->Dict.get("LOG_LEVEL")->Option.isNone {
249
+ variables->Dict.set(
250
+ "LOG_LEVEL",
251
+ Util_LogRetention.logLevelFor(
252
+ ~stack,
253
+ ~prodStacks,
254
+ ~configOverride=?Util_LocalConfig.get("logLevel"),
255
+ )->Pulumi.Input.make,
256
+ )
257
+ }
258
+
232
259
  // ESM self-containment (Option C): every code archive built by
233
260
  // Util_Bundle.buildCodeArchive ships register-hook.mjs + layer-resolver.mjs at
234
261
  // /var/task. --import registers the resolve hook so the ESM entry point (and the
@@ -263,13 +290,44 @@ let makeFromCodeAsset: (
263
290
  ~opts?,
264
291
  )
265
292
 
266
- // Optional CloudWatch log-group with explicit retention. When not set, Lambda
267
- // auto-creates a log group with no retention (logs accumulate indefinitely).
268
- logRetentionDays->Option.forEach(days => {
293
+ // Managed CloudWatch log-group with explicit retention. Created when the caller
294
+ // pins `~logRetentionDays` (the app-developer opt-in) OR the stack manages its
295
+ // groups — every stack by default, bar any named in `unmanagedLogGroupStacks`
296
+ // (see `Util_LogRetention.managesLogGroup`). When neither holds, Lambda
297
+ // auto-creates an unmanaged group with no retention.
298
+ //
299
+ // Precedence for the retention value: explicit `~logRetentionDays` → the
300
+ // `logRetentionDays` config key → the environment tier default.
301
+ //
302
+ // The group name derives from the function's *physical* name output, not the
303
+ // logical `name`. `Lambda.Function` has no `name` arg, so its physical name
304
+ // always carries Pulumi's `-<7hex>` suffix; a literal `/aws/lambda/${name}`
305
+ // would create an empty group beside the real one, leave orphan accumulation
306
+ // unchanged, and collide across stacks sharing an account.
307
+ let managedRetention = switch (
308
+ logRetentionDays,
309
+ Util_LogRetention.managesLogGroup(~stack, ~unmanagedStacks),
310
+ ) {
311
+ | (Some(days), _) => Some(days)
312
+ | (None, true) =>
313
+ Some(
314
+ Util_LogRetention.retentionDaysFor(
315
+ ~stack,
316
+ ~prodStacks,
317
+ ~configOverride=?Util_LocalConfig.get("logRetentionDays")->Option.flatMap(s =>
318
+ Int.fromString(s)
319
+ ),
320
+ ),
321
+ )
322
+ | (None, false) => None
323
+ }
324
+ managedRetention->Option.forEach(days => {
269
325
  let logGroup = Cloudwatch.LogGroup.make(
270
326
  ~name=`${name}LogGroup`,
271
327
  ~args={
272
- name: `/aws/lambda/${name}`->Pulumi.Input.make,
328
+ name: lambda.name
329
+ ->Pulumi.Output.apply(n => `/aws/lambda/${n}`)
330
+ ->Pulumi.Output.asInput,
273
331
  retentionInDays: days->Pulumi.Input.make,
274
332
  tags: tagsFor(~resourceName=`${name}LogGroup`, ~role=Logs),
275
333
  },