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

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
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.253 (2026-08-01)
7
+
8
+ * feat(aws,core,spec)!: qualify store prefixes by plugin and refuse name collisions ([da39405](https://github.com/ReventlessDev/reventless-core/commit/da394059d9f8f981bf7adc79e2c1ce2b429e0267))
9
+ * feat(core,aws)!: name task buckets for their plugin and task ([05203cb](https://github.com/ReventlessDev/reventless-core/commit/05203cb3b0b019401a6f32cf78109f397b6799d5))
10
+
11
+ ### BREAKING CHANGES
12
+
13
+ * objects minted under the old bare prefix are orphaned and their
14
+ refs unresolvable. The migration is `seed:reset` for the owning plugin, then
15
+ re-seed. Legacy-prefix grandfathering was considered and deliberately dropped —
16
+ it would have added a permanent prefix SET across the deploy argument, both store
17
+ configs, the presign IAM fan-out, the release scope check and the stack output,
18
+ to spare a disposable stack one wipe.
19
+ * a bucket name change is a replace. TaskBucket_S3 now sets
20
+ forceDestroy on disposable stacks (Util_StoreLayout.protectionFor), so pr-*
21
+ stacks recreate cleanly; protected stacks must have the task bucket drained by
22
+ hand before the first deploy after this, or the replace fails BucketNotEmpty.
23
+
24
+
25
+
6
26
  # 3.0.0-alpha.252 (2026-08-01)
7
27
 
8
28
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.252",
3
+ "version": "3.0.0-alpha.253",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -13,16 +13,16 @@
13
13
  "uuid": "^13.0.0",
14
14
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.0",
15
15
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
16
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
17
16
  "@reventlessdev/rescript-node": "2.0.0-alpha.0",
17
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
18
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.62",
19
19
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
20
20
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
21
- "@reventlessdev/reventless-infra": "3.0.0-alpha.116",
22
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
23
- "@reventlessdev/reventless-spec": "3.0.0-alpha.91",
24
- "@reventlessdev/reventless-core": "3.0.0-alpha.199",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.63"
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"
26
26
  },
27
27
  "devDependencies": {
28
28
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -1537,6 +1537,30 @@ module MakeWithConfig = (
1537
1537
  : Array.concat(acc, [(plugin, store)])
1538
1538
  )
1539
1539
 
1540
+ // Refuse two stores that cannot be told apart, BEFORE a single resource is
1541
+ // created. Left to the deploy, the same collision surfaces as a CloudFront
1542
+ // error about a duplicate path pattern — minutes in, naming a path pattern
1543
+ // rather than the two plugins — and only after the intermixed objects and
1544
+ // the over-broad presign grant have already been provisioned.
1545
+ //
1546
+ // Checked on the prefix, so it costs nothing to keep once prefixes are
1547
+ // qualified: a qualified prefix simply stops colliding, while the check
1548
+ // still guards any bare prefix that outlives the change.
1549
+ switch ReventlessCore.StorePrefixCollision.collisionsFor(
1550
+ ~stores=declaredStores->Array.map(((plugin, store)) => {
1551
+ ReventlessCore.StorePrefixCollision.qualified: `${plugin}.${store}`,
1552
+ prefix: Util_StoreLayout.keyPrefixFor(~plugin, ~store),
1553
+ }),
1554
+ ) {
1555
+ | [] => ()
1556
+ | collisions =>
1557
+ JsError.throwWithMessage(
1558
+ collisions
1559
+ ->Array.map(ReventlessCore.StorePrefixCollision.collisionMessage)
1560
+ ->Array.join("\n\n"),
1561
+ )
1562
+ }
1563
+
1540
1564
  if declaredStores->Array.length > 0 {
1541
1565
  // The fail-open in `layoutFor` is only dangerous while it is silent: a
1542
1566
  // production stack that is not on the prod list gets the shared layout and
@@ -1567,7 +1591,7 @@ module MakeWithConfig = (
1567
1591
  ~plugin,
1568
1592
  ~store,
1569
1593
  )
1570
- let keyPrefix = Util_StoreLayout.keyPrefixFor(~store)
1594
+ let keyPrefix = Util_StoreLayout.keyPrefixFor(~plugin, ~store)
1571
1595
  let bucket = switch storeBuckets->Dict.get(bucketName) {
1572
1596
  | Some(b) => b
1573
1597
  | None =>
@@ -59,6 +59,7 @@ import * as Geocoder_AwsLocation$ReventlessAws from "./adapter/Geocoder/Geocoder
59
59
  import * as QueryEngine_DynamoDb$ReventlessAws from "./adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs";
60
60
  import * as PluginRuntime_Builder$ReventlessAws from "./plugin/runtime/PluginRuntime_Builder.res.mjs";
61
61
  import * as PluginsReadModelSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
62
+ import * as StorePrefixCollision$ReventlessCore from "@reventlessdev/reventless-core/src/util/StorePrefixCollision.res.mjs";
62
63
  import * as ExtensionPoint_Builder$ReventlessAws from "./components/ExtensionPoint_Builder.res.mjs";
63
64
  import * as StateViewSlice_Builder$ReventlessAws from "./components/StateViewSlice_Builder.res.mjs";
64
65
  import * as Task_Builder_PerBucket$ReventlessAws from "./components/Task_Builder_PerBucket.res.mjs";
@@ -904,6 +905,17 @@ function MakeWithConfig(Config) {
904
905
  ]]);
905
906
  }
906
907
  });
908
+ let collisions = StorePrefixCollision$ReventlessCore.collisionsFor(declaredStores.map(param => {
909
+ let store = param[1];
910
+ let plugin = param[0];
911
+ return {
912
+ qualified: plugin + `.` + store,
913
+ prefix: Util_StoreLayout$ReventlessAws.keyPrefixFor(plugin, store)
914
+ };
915
+ }));
916
+ if (collisions.length !== 0) {
917
+ Stdlib_JsError.throwWithMessage(collisions.map(StorePrefixCollision$ReventlessCore.collisionMessage).join("\n\n"));
918
+ }
907
919
  if (declaredStores.length !== 0) {
908
920
  let tmp;
909
921
  tmp = storeLayout === "PerStore" ? "bucket per store" : "shared bucket, {store}/… prefixes";
@@ -917,7 +929,7 @@ function MakeWithConfig(Config) {
917
929
  let store = param[1];
918
930
  let plugin = param[0];
919
931
  let bucketName = Util_StoreLayout$ReventlessAws.bucketNameFor(storeLayout, stackName, plugin, store);
920
- let keyPrefix = Util_StoreLayout$ReventlessAws.keyPrefixFor(store);
932
+ let keyPrefix = Util_StoreLayout$ReventlessAws.keyPrefixFor(plugin, store);
921
933
  let b = storeBuckets[bucketName];
922
934
  let bucket;
923
935
  if (b !== undefined) {
@@ -2038,6 +2050,17 @@ function Make($star) {
2038
2050
  ]]);
2039
2051
  }
2040
2052
  });
2053
+ let collisions = StorePrefixCollision$ReventlessCore.collisionsFor(declaredStores.map(param => {
2054
+ let store = param[1];
2055
+ let plugin = param[0];
2056
+ return {
2057
+ qualified: plugin + `.` + store,
2058
+ prefix: Util_StoreLayout$ReventlessAws.keyPrefixFor(plugin, store)
2059
+ };
2060
+ }));
2061
+ if (collisions.length !== 0) {
2062
+ Stdlib_JsError.throwWithMessage(collisions.map(StorePrefixCollision$ReventlessCore.collisionMessage).join("\n\n"));
2063
+ }
2041
2064
  if (declaredStores.length !== 0) {
2042
2065
  let tmp;
2043
2066
  tmp = storeLayout === "PerStore" ? "bucket per store" : "shared bucket, {store}/… prefixes";
@@ -2051,7 +2074,7 @@ function Make($star) {
2051
2074
  let store = param[1];
2052
2075
  let plugin = param[0];
2053
2076
  let bucketName = Util_StoreLayout$ReventlessAws.bucketNameFor(storeLayout, stackName, plugin, store);
2054
- let keyPrefix = Util_StoreLayout$ReventlessAws.keyPrefixFor(store);
2077
+ let keyPrefix = Util_StoreLayout$ReventlessAws.keyPrefixFor(plugin, store);
2055
2078
  let b = storeBuckets[bucketName];
2056
2079
  let bucket;
2057
2080
  if (b !== undefined) {
@@ -125,10 +125,21 @@ let connect = (
125
125
  let make: ReventlessCore.Task_Adapter.bucketMaker<bucketParts> = (~name, ~opts) => {
126
126
  let opts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
127
127
 
128
+ // A task bucket holds transient input a slice consumes, so disposability
129
+ // follows the stack, exactly as it does for declared stores. It matters
130
+ // because a bucket name change is a *replace*, and a replace of a non-empty
131
+ // bucket fails the deploy with `BucketNotEmpty` — which would wedge precisely
132
+ // the throwaway stacks that are recreated most often. Protected stacks keep
133
+ // the safe default and drain by hand.
134
+ let forceDestroy =
135
+ Util_StoreLayout.protectionFor(~stack=Pulumi.Pulumi.getStackName()) ==
136
+ Util_StoreLayout.Unprotected
137
+
128
138
  let bucket = {
129
139
  PulumiAws.S3.Bucket.make(
130
140
  ~name,
131
141
  ~args={
142
+ forceDestroy: forceDestroy->Pulumi.Input.make,
132
143
  corsRules: [
133
144
  {
134
145
  PulumiAws.S3.Bucket.allowedHeaders: ["*"],
@@ -11,6 +11,7 @@ import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adap
11
11
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
12
  import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
13
13
  import * as Adapter_Helpers$ReventlessAws from "../Adapter_Helpers.res.mjs";
14
+ import * as Util_StoreLayout$ReventlessAws from "../../util/Util_StoreLayout.res.mjs";
14
15
  import * as TaskBucket_S3_Runtime$ReventlessAws from "./TaskBucket_S3_Runtime.res.mjs";
15
16
 
16
17
  function subscribeLambda2S3Bucket(lambda, name, bucket, opts) {
@@ -75,6 +76,7 @@ function connect(name, bucket, bucketMode, commandTopics, runtime, opts) {
75
76
 
76
77
  function make(name, opts) {
77
78
  let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
79
+ let forceDestroy = Util_StoreLayout$ReventlessAws.protectionFor(Pulumi.getStack(), undefined) === "Unprotected";
78
80
  let bucket = new (Aws.s3.Bucket)(name, {
79
81
  corsRules: [{
80
82
  allowedHeaders: ["*"],
@@ -93,6 +95,7 @@ function make(name, opts) {
93
95
  ],
94
96
  maxAgeSeconds: 3000
95
97
  }],
98
+ forceDestroy: forceDestroy,
96
99
  tags: AWS_Tags$ReventlessAws.make(name, Task$ReventlessCore.componentType, {
97
100
  TAG: "Other",
98
101
  _0: "Bucket"
@@ -123,6 +123,10 @@ type releaseOutcome = Released | Refused(string)
123
123
  // Key must sit under this store's served prefix (`not_in_store`) and under the
124
124
  // caller's own identity segment within it (`not_yours`); an empty `sub` is
125
125
  // `unauthenticated` (the authorizer should have refused first — this is a guard).
126
+ //
127
+ // One prefix, not a set: changing a store's prefix strands the refs already
128
+ // minted under the old one, so a prefix change is a breaking change resolved by
129
+ // emptying the store and re-seeding, never by carrying both prefixes forever.
126
130
  let scopeCheck = (~key: string, ~sub: string, ~servedPrefix: string): result<unit, string> =>
127
131
  if sub == "" {
128
132
  Error("unauthenticated")
@@ -109,20 +109,36 @@ let bucketNameFor = (~layout: t, ~stack: string, ~plugin: string, ~store: string
109
109
  }
110
110
 
111
111
  /**
112
- The prefix a store's object keys are rooted at — the store name, in **both**
112
+ The prefix a store's object keys are rooted at — `{plugin}/{store}`, in **both**
113
113
  layouts.
114
114
 
115
- This is the property that keeps the two layouts one model rather than a fork.
116
- The presign service mints `{store}/{identity}{uuid}/{file}` either way, so the
117
- stored ref is `/{store}/…` regardless of which bucket sits behind it, and only
118
- the CDN origin differs. Refs live in an append-only event log: one that encoded
119
- its bucket layout would be environment-specific and unrewritable, so a prod dump
120
- restored into a PR stack would carry refs that cannot resolve.
115
+ Layout-invariance is what keeps the two layouts one model rather than a fork.
116
+ The presign service mints `{prefix}/{identity}/{uuid}/{file}` either way, so the
117
+ stored ref is `/{prefix}/…` regardless of which bucket sits behind it, and only
118
+ the CDN origin differs. Refs live in an append-only event log: a prefix that
119
+ encoded its bucket layout would be environment-specific and unrewritable, so a
120
+ prod dump restored into a PR stack would carry refs that cannot resolve. Plugin
121
+ and store are stack-invariant, so qualifying by plugin costs none of that.
122
+
123
+ Qualified because the prefix is a **platform-global** namespace: one distribution
124
+ fronts every store bucket and takes one cache behavior per prefix, so a bare
125
+ store name made two plugins declaring `productImages` unroutable — in either
126
+ layout, since the per-store layout still lands both prefixes on the one
127
+ distribution. Qualifying narrows uniqueness from "per platform" to "per plugin",
128
+ which is what plugin isolation wants and what a platform composing plugins it
129
+ did not author needs.
121
130
 
122
131
  The cost is a slightly redundant prefix inside a dedicated bucket
123
- (`catalog-productImages/productImages/…`). Take the redundancy.
132
+ (`catalog-productImages/Catalog/productImages/…`). Take the redundancy.
133
+
134
+ **Changing this string is breaking.** Minted refs are `/{prefix}/…` in an
135
+ append-only event log, so objects written under an earlier prefix become
136
+ unreachable and their refs unresolvable. There is deliberately no grandfathering
137
+ machinery: a stack that predates a change to this function empties its stores
138
+ (`seed:reset`, which wipes per plugin) and re-seeds. Carrying a permanent prefix
139
+ set to spare a disposable stack one wipe is the worse trade.
124
140
  */
125
- let keyPrefixFor = (~store: string): string => store
141
+ let keyPrefixFor = (~plugin: string, ~store: string): string => `${plugin}/${store}`
126
142
 
127
143
  /**
128
144
  Who serves the declared stores — and the answer is never "both".
@@ -28,8 +28,8 @@ function bucketNameFor(layout, stack, plugin, store) {
28
28
  }
29
29
  }
30
30
 
31
- function keyPrefixFor(store) {
32
- return store;
31
+ function keyPrefixFor(plugin, store) {
32
+ return plugin + `/` + store;
33
33
  }
34
34
 
35
35
  function servingFor(hasHostUiBundle, declaredBucketCount) {
@@ -222,8 +222,21 @@ describe("Util_StoreLayout.bucketNameFor", () => {
222
222
  describe("Util_StoreLayout.keyPrefixFor", () => {
223
223
  // The assertion the whole dual-layout scheme rests on: a ref is
224
224
  // layout-independent, so the same declaration produces the same stored string
225
- // on a per-store stack and a shared one.
226
- testSync("the key prefix is the store name in both layouts", () =>
227
- expect(Util_StoreLayout.keyPrefixFor(~store="productImages"))->toBe("productImages")
225
+ // on a per-store stack and a shared one. Plugin and store are both
226
+ // stack-invariant, so qualifying by plugin keeps that property.
227
+ testSync("the key prefix qualifies the store with its plugin", () =>
228
+ expect(Util_StoreLayout.keyPrefixFor(~plugin="Catalog", ~store="productImages"))->toBe(
229
+ "Catalog/productImages",
230
+ )
231
+ )
232
+
233
+ // The reason it is qualified: the prefix is a platform-global namespace (one
234
+ // distribution, one cache behavior per prefix), so two plugins declaring one
235
+ // store name were unroutable in EITHER layout until the plugin qualified them.
236
+ testSync("two plugins declaring one store name get distinct prefixes", () =>
237
+ expect(
238
+ Util_StoreLayout.keyPrefixFor(~plugin="Catalog", ~store="productImages") !=
239
+ Util_StoreLayout.keyPrefixFor(~plugin="Ordering", ~store="productImages"),
240
+ )->toBe(true)
228
241
  )
229
242
  })
@@ -113,8 +113,11 @@ globalThis.describe("Util_StoreLayout.bucketNameFor", () => {
113
113
  });
114
114
 
115
115
  globalThis.describe("Util_StoreLayout.keyPrefixFor", () => {
116
- globalThis.test("the key prefix is the store name in both layouts", () => {
117
- globalThis.expect(Util_StoreLayout$ReventlessAws.keyPrefixFor("productImages")).toBe("productImages");
116
+ globalThis.test("the key prefix qualifies the store with its plugin", () => {
117
+ globalThis.expect(Util_StoreLayout$ReventlessAws.keyPrefixFor("Catalog", "productImages")).toBe("Catalog/productImages");
118
+ });
119
+ globalThis.test("two plugins declaring one store name get distinct prefixes", () => {
120
+ globalThis.expect(Util_StoreLayout$ReventlessAws.keyPrefixFor("Catalog", "productImages") !== Util_StoreLayout$ReventlessAws.keyPrefixFor("Ordering", "productImages")).toBe(true);
118
121
  });
119
122
  });
120
123