@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 +20 -0
- package/package.json +7 -7
- package/src/Platform.res +25 -1
- package/src/Platform.res.mjs +25 -2
- package/src/adapter/Task/TaskBucket_S3.res +11 -0
- package/src/adapter/Task/TaskBucket_S3.res.mjs +3 -0
- package/src/adapter/Upload/Upload_Presign_S3_Ops.res +4 -0
- package/src/util/Util_StoreLayout.res +25 -9
- package/src/util/Util_StoreLayout.res.mjs +2 -2
- package/tests/Util_StoreLayoutTest.res +16 -3
- package/tests/Util_StoreLayoutTest.res.mjs +5 -2
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.
|
|
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-
|
|
22
|
-
"@reventlessdev/reventless-
|
|
23
|
-
"@reventlessdev/reventless-
|
|
24
|
-
"@reventlessdev/reventless-
|
|
25
|
-
"@reventlessdev/reventless-
|
|
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 =>
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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 —
|
|
112
|
+
The prefix a store's object keys are rooted at — `{plugin}/{store}`, in **both**
|
|
113
113
|
layouts.
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
The presign service mints `{
|
|
117
|
-
stored ref is `/{
|
|
118
|
-
the CDN origin differs. Refs live in an append-only event log:
|
|
119
|
-
its bucket layout would be environment-specific and unrewritable, so a
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
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
|
|