@reventlessdev/reventless-core 3.0.0-alpha.198 → 3.0.0-alpha.200
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 +35 -0
- package/package.json +6 -6
- package/src/admin/Platform_AdminApi.res +23 -0
- package/src/admin/Platform_AdminApi.res.mjs +12 -0
- package/src/components/Task/Task_Builder.res +75 -4
- package/src/components/Task/Task_Builder.res.mjs +33 -1
- package/src/plugin/component/Plugin_Structure.res +8 -5
- package/src/util/StorePrefixCollision.res +96 -0
- package/src/util/StorePrefixCollision.res.mjs +55 -0
- package/tests/StorePrefixCollisionTest.res +160 -0
- package/tests/StorePrefixCollisionTest.res.mjs +174 -0
- package/tests/TaskBucketNamingTest.res +97 -0
- package/tests/TaskBucketNamingTest.res.mjs +60 -0
- package/tests/api/SuryToJsonSchemaTest.res +46 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +39 -0
- package/tests/fixtures/plugin-lifecycle/2026-07-22-versionconnected-no-requiredstores.json +1 -0
- package/tests/fixtures/plugin-lifecycle/2026-07-22-versionsuperseded-nested-definitions.json +1 -0
- package/tests/fixtures/plugin-lifecycle/2026-07-28-versionconnected-requiredstores-only.json +1 -0
- package/tests/fixtures/plugin-lifecycle/2026-07-30-versionconnected-declarations-without-annotation.json +1 -0
- package/tests/fixtures/plugin-lifecycle/2026-07-30-versiondisconnected-declarations-without-annotation.json +1 -0
- package/tests/fixtures/plugin-lifecycle/README.md +36 -0
- package/tests/plugin/PluginDefinitionRequiredScalarsTest.res +42 -0
- package/tests/plugin/PluginDefinitionRequiredScalarsTest.res.mjs +45 -0
- package/tests/plugin/PluginDefinitionScalars.res +78 -0
- package/tests/plugin/PluginDefinitionScalars.res.mjs +110 -0
- package/tests/plugin/PluginLifecycleCorpus.res +42 -0
- package/tests/plugin/PluginLifecycleCorpus.res.mjs +39 -0
- package/tests/plugin/PluginLifecycleCorpusTest.res +50 -0
- package/tests/plugin/PluginLifecycleCorpusTest.res.mjs +42 -0
- package/tests/plugin/PluginStructureTest.res +3 -3
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +103 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,41 @@
|
|
|
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.200 (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
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **spec:** a coordinate is one declared point, not two fields and a name guess ([bfe2f90](https://github.com/ReventlessDev/reventless-core/commit/bfe2f90241492422d6c242e3f50e31b81ed2a010))
|
|
13
|
+
|
|
14
|
+
### BREAKING CHANGES
|
|
15
|
+
|
|
16
|
+
* objects minted under the old bare prefix are orphaned and their
|
|
17
|
+
refs unresolvable. The migration is `seed:reset` for the owning plugin, then
|
|
18
|
+
re-seed. Legacy-prefix grandfathering was considered and deliberately dropped —
|
|
19
|
+
it would have added a permanent prefix SET across the deploy argument, both store
|
|
20
|
+
configs, the presign IAM fan-out, the release scope check and the stack output,
|
|
21
|
+
to spare a disposable stack one wipe.
|
|
22
|
+
* a bucket name change is a replace. TaskBucket_S3 now sets
|
|
23
|
+
forceDestroy on disposable stacks (Util_StoreLayout.protectionFor), so pr-*
|
|
24
|
+
stacks recreate cleanly; protected stacks must have the task bucket drained by
|
|
25
|
+
hand before the first deploy after this, or the replace fails BucketNotEmpty.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# 3.0.0-alpha.199 (2026-08-01)
|
|
30
|
+
|
|
31
|
+
### Bug Fixes
|
|
32
|
+
|
|
33
|
+
* **core:** don't flag js_nullable scalars in the required-scalar guard ([fb394a3](https://github.com/ReventlessDev/reventless-core/commit/fb394a3f556ffa6a95b1cf746cfd55ded8a5021a))
|
|
34
|
+
* **spec,core:** heal a missing scalar on read ([de9a98e](https://github.com/ReventlessDev/reventless-core/commit/de9a98ec5fe11ec19bef80626e99244d9c30a6b1))
|
|
35
|
+
* **spec,core:** make the storageRef annotation optional, as its readers already are ([c8477c5](https://github.com/ReventlessDev/reventless-core/commit/c8477c5c34384b864c06716dc5896310629dc349))
|
|
36
|
+
### Features
|
|
37
|
+
|
|
38
|
+
* **upload:** add the release half of the upload contract via the domain API ([448f887](https://github.com/ReventlessDev/reventless-core/commit/448f88714a5875be420f794a08d482fcf4ba8404))
|
|
39
|
+
|
|
40
|
+
|
|
6
41
|
# 3.0.0-alpha.198 (2026-08-01)
|
|
7
42
|
|
|
8
43
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.200",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
33
|
-
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.0",
|
|
34
33
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.0",
|
|
36
34
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
35
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.0",
|
|
36
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.0",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.0",
|
|
38
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.117",
|
|
38
39
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
|
-
"@reventlessdev/reventless-
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.90"
|
|
40
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.92",
|
|
41
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.30"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
@@ -77,6 +77,29 @@ let pluginStatusSubscriptionSource: GraphQL_Stitcher.subscriptionSource = {
|
|
|
77
77
|
mutations: ["Platform_PluginStatusChanged"],
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Upload service (route B): mint and release object-store uploads through the GraphQL
|
|
81
|
+
// API instead of an anonymous Function URL, so AppSync's Cognito authorizer
|
|
82
|
+
// authenticates the caller and the verified identity reaches the resolver. `store` is
|
|
83
|
+
// the qualified `{plugin}.{store}` the caller declares; the resolver Lambda maps it to
|
|
84
|
+
// a bucket/prefix. No subscription source — these are request/response operations, not
|
|
85
|
+
// Source-C event fan-ins.
|
|
86
|
+
//
|
|
87
|
+
// These constants live here (next to the other cross-provider platform SDL constants)
|
|
88
|
+
// but are deliberately NOT part of `baseFragment`: the admin base is Admin-group gated
|
|
89
|
+
// (`injectAwsAuthAll(~group="Admin")`), and uploads are a regular authenticated-user
|
|
90
|
+
// operation. They are added instead to the **domain** API's platform-owned base
|
|
91
|
+
// (`domainBaseFragment` in the AWS/local Platform), which takes the default
|
|
92
|
+
// `AllowAuthenticated` auth. See [docs/plans/upload-release-path.md] § "Which API".
|
|
93
|
+
let uploadTypes = [
|
|
94
|
+
`type Upload_Ticket {\n uploadUrl: String!\n storageRef: String!\n}`,
|
|
95
|
+
`type Upload_ReleaseResult {\n released: Boolean!\n reason: String\n}`,
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
let uploadMutationFields = [
|
|
99
|
+
` Upload_Presign(store: ID!, fileName: String!, contentType: String): Upload_Ticket`,
|
|
100
|
+
` Upload_Release(store: ID!, storageRef: String!): Upload_ReleaseResult`,
|
|
101
|
+
]
|
|
102
|
+
|
|
80
103
|
let baseFragment = (~cloner: bool) => {
|
|
81
104
|
let base = GraphQL_FragmentGenerator.generate(
|
|
82
105
|
~mutationEntries=mutationEntries(~cloner),
|
|
@@ -71,6 +71,16 @@ let pluginStatusSubscriptionSource = {
|
|
|
71
71
|
mutations: pluginStatusSubscriptionSource_mutations
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
let uploadTypes = [
|
|
75
|
+
`type Upload_Ticket {\n uploadUrl: String!\n storageRef: String!\n}`,
|
|
76
|
+
`type Upload_ReleaseResult {\n released: Boolean!\n reason: String\n}`
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
let uploadMutationFields = [
|
|
80
|
+
` Upload_Presign(store: ID!, fileName: String!, contentType: String): Upload_Ticket`,
|
|
81
|
+
` Upload_Release(store: ID!, storageRef: String!): Upload_ReleaseResult`
|
|
82
|
+
];
|
|
83
|
+
|
|
74
84
|
function baseFragment(cloner) {
|
|
75
85
|
let base = GraphQL_FragmentGenerator$ReventlessCore.generate(mutationEntries(cloner), PluginBaseFragment$ReventlessCore.queryEntries);
|
|
76
86
|
let parts = GraphQL_Stitcher$ReventlessCore.decode(base);
|
|
@@ -108,6 +118,8 @@ export {
|
|
|
108
118
|
pluginStatusMutationFields,
|
|
109
119
|
pluginStatusSubscriptionField,
|
|
110
120
|
pluginStatusSubscriptionSource,
|
|
121
|
+
uploadTypes,
|
|
122
|
+
uploadMutationFields,
|
|
111
123
|
baseFragment,
|
|
112
124
|
}
|
|
113
125
|
/* cloneArgsSchema Not a pure module */
|
|
@@ -14,6 +14,71 @@ let pascalCase = s =>
|
|
|
14
14
|
)
|
|
15
15
|
->Array.join("")
|
|
16
16
|
|
|
17
|
+
// Split words out of a PascalCase/camelCase run so kebab-casing keeps the word
|
|
18
|
+
// boundaries. Two passes: `aB` splits an ordinary hump, `ABc` splits the tail of
|
|
19
|
+
// an acronym off the word that follows it ("HTTPServer" -> "HTTP-Server").
|
|
20
|
+
let humpBoundary = %re("/([a-z0-9])([A-Z])/g")
|
|
21
|
+
let acronymBoundary = %re("/([A-Z]+)([A-Z][a-z])/g")
|
|
22
|
+
|
|
23
|
+
// Kebab-case an identifier for use in an S3 bucket name: "ImportProducts" and
|
|
24
|
+
// "product-imports" both reduce to "import-products".
|
|
25
|
+
//
|
|
26
|
+
// S3 lowercases bucket names, so a PascalCase segment collapses into an
|
|
27
|
+
// unreadable run-on ("importproductsproductimportsbucket"). Kebab survives the
|
|
28
|
+
// lowercasing with its word boundaries intact, which is why the framework's
|
|
29
|
+
// declared-store buckets (`Util_StoreLayout.bucketNameFor`) already use it.
|
|
30
|
+
let kebabCase = s =>
|
|
31
|
+
s
|
|
32
|
+
->String.replaceRegExp(acronymBoundary, "$1-$2")
|
|
33
|
+
->String.replaceRegExp(humpBoundary, "$1-$2")
|
|
34
|
+
->String.split("-")
|
|
35
|
+
->Array.flatMap(p => p->String.split("_"))
|
|
36
|
+
->Array.filter(p => p->String.length > 0)
|
|
37
|
+
->Array.map(String.toLowerCase)
|
|
38
|
+
->Array.join("-")
|
|
39
|
+
|
|
40
|
+
// S3 caps a bucket name at 63 characters and Pulumi appends `-` plus a 7-char
|
|
41
|
+
// uniqueness suffix, so the composed name has 55 to work with.
|
|
42
|
+
let maxBucketNameLength = 55
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
The Pulumi resource name for a task's bucket: `{plugin}-{task}` for a task's
|
|
46
|
+
default bucket, `{plugin}-{task}-{bucketId}` when the declaration names one.
|
|
47
|
+
|
|
48
|
+
`plugin` is the ambient plugin under construction, absent for a task built
|
|
49
|
+
outside any plugin. No `Bucket` suffix — `aws:s3/bucket` and the
|
|
50
|
+
`reventless:role=Bucket` tag both already say what it is.
|
|
51
|
+
|
|
52
|
+
Composed rather than sanitised, so a name too long for S3 is the caller's to fix:
|
|
53
|
+
truncating here would silently manufacture a collision between two long names
|
|
54
|
+
that share a prefix.
|
|
55
|
+
*/
|
|
56
|
+
let bucketResourceName = (
|
|
57
|
+
~plugin: option<string>,
|
|
58
|
+
~task: string,
|
|
59
|
+
~bucketId: option<string>,
|
|
60
|
+
): string => {
|
|
61
|
+
let name =
|
|
62
|
+
[plugin, Some(task), bucketId]
|
|
63
|
+
->Array.filterMap(segment => segment)
|
|
64
|
+
->Array.map(kebabCase)
|
|
65
|
+
->Array.filter(segment => segment != "")
|
|
66
|
+
->Array.join("-")
|
|
67
|
+
if name->String.length > maxBucketNameLength {
|
|
68
|
+
JsError.throwWithMessage(
|
|
69
|
+
`Task bucket name "${name}" is ${name
|
|
70
|
+
->String.length
|
|
71
|
+
->Int.toString} characters; S3 allows ${maxBucketNameLength->Int.toString} once Pulumi's ` ++
|
|
72
|
+
`uniqueness suffix is added. Shorten the task name` ++
|
|
73
|
+
switch bucketId {
|
|
74
|
+
| Some(id) => ` or the bucket id "${id}".`
|
|
75
|
+
| None => "."
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
name
|
|
80
|
+
}
|
|
81
|
+
|
|
17
82
|
module Make = (
|
|
18
83
|
Spec: Task.Spec,
|
|
19
84
|
RuntimeEnvironment: Runtime.Environment,
|
|
@@ -169,11 +234,17 @@ module Make = (
|
|
|
169
234
|
buckets
|
|
170
235
|
->Array.map(bucketSpec => {
|
|
171
236
|
let bucketName = bucketSpec.bucketName->Option.getOr("Bucket")
|
|
172
|
-
// `bucketStem` is the PascalCase
|
|
173
|
-
// default unnamed bucket
|
|
174
|
-
//
|
|
237
|
+
// `bucketStem` is the PascalCase segment naming this bucket's Lambda
|
|
238
|
+
// (empty for the default unnamed bucket). `bucketName` above remains the
|
|
239
|
+
// runtime key into `Task.bucketNames`; `name` is the bucket's own
|
|
240
|
+
// resource name, kebab-cased and plugin-qualified because S3 lowercases
|
|
241
|
+
// it — see `bucketResourceName`.
|
|
175
242
|
let bucketStem = bucketSpec.bucketName->Option.mapOr("", pascalCase)
|
|
176
|
-
let name =
|
|
243
|
+
let name = bucketResourceName(
|
|
244
|
+
~plugin=ResourceAttribution.current.contents.plugin,
|
|
245
|
+
~task=taskName,
|
|
246
|
+
~bucketId=bucketSpec.bucketName,
|
|
247
|
+
)
|
|
177
248
|
let bucket = TaskBucket.make(~name, ~opts)
|
|
178
249
|
let opts = {Pulumi.ComponentResource.parent: bucket.parts->Pulumi.Resource.makeFromJs}
|
|
179
250
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
5
|
import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
|
|
5
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
7
|
import * as Pulumi from "@pulumi/pulumi";
|
|
8
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
7
9
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
8
10
|
import * as Task$ReventlessCore from "./Task.res.mjs";
|
|
9
11
|
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
@@ -13,6 +15,7 @@ import * as Util_Promise$ReventlessCore from "../../util/Util_Promise.res.mjs";
|
|
|
13
15
|
import * as ComponentType$ReventlessCore from "../../ComponentType.res.mjs";
|
|
14
16
|
import * as RuntimeHints$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/RuntimeHints.res.mjs";
|
|
15
17
|
import * as Builder_Helpers$ReventlessCore from "../Builder_Helpers.res.mjs";
|
|
18
|
+
import * as ResourceAttribution$ReventlessCore from "../../ResourceAttribution.res.mjs";
|
|
16
19
|
|
|
17
20
|
let log = Logger$ReventlessCore.fromEnv();
|
|
18
21
|
|
|
@@ -20,6 +23,28 @@ function pascalCase(s) {
|
|
|
20
23
|
return s.split("-").flatMap(p => p.split("_")).filter(p => p.length > 0).map(p => p.slice(0, 1).toUpperCase() + p.slice(1, p.length)).join("");
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
let humpBoundary = /([a-z0-9])([A-Z])/g;
|
|
27
|
+
|
|
28
|
+
let acronymBoundary = /([A-Z]+)([A-Z][a-z])/g;
|
|
29
|
+
|
|
30
|
+
function kebabCase(s) {
|
|
31
|
+
return s.replace(acronymBoundary, "$1-$2").replace(humpBoundary, "$1-$2").split("-").flatMap(p => p.split("_")).filter(p => p.length > 0).map(prim => prim.toLowerCase()).join("-");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bucketResourceName(plugin, task, bucketId) {
|
|
35
|
+
let name = Stdlib_Array.filterMap([
|
|
36
|
+
plugin,
|
|
37
|
+
task,
|
|
38
|
+
bucketId
|
|
39
|
+
], segment => segment).map(kebabCase).filter(segment => segment !== "").join("-");
|
|
40
|
+
if (name.length > 55) {
|
|
41
|
+
Stdlib_JsError.throwWithMessage(`Task bucket name "` + name + `" is ` + name.length.toString() + ` characters; S3 allows ` + (55).toString() + ` once Pulumi's uniqueness suffix is added. Shorten the task name` + (
|
|
42
|
+
bucketId !== undefined ? ` or the bucket id "` + bucketId + `".` : "."
|
|
43
|
+
));
|
|
44
|
+
}
|
|
45
|
+
return name;
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
function Make(Spec) {
|
|
24
49
|
return RuntimeEnvironment => (EventCollectorChannel => (EventCollectorRuntimeBuilder => (TaskRuntimeBuilder => (TaskBucket => (SpecificSideEffectHandler => (Defaults => {
|
|
25
50
|
let make = (queryBucketName, scheduler, schedulerRoleUrn, publishToAggregates, queryEngine, resourceNaming, allAggregates, runtime, opts) => Component$ReventlessCore.make(ComponentType$ReventlessCore.toString(Task$ReventlessCore.componentType), Spec.name, (extra, extra$1) => {
|
|
@@ -85,7 +110,7 @@ function Make(Spec) {
|
|
|
85
110
|
let bucketNames = Stdlib_Option.map(config.buckets, buckets => Object.fromEntries(buckets.map(bucketSpec => {
|
|
86
111
|
let bucketName = Stdlib_Option.getOr(bucketSpec.bucketName, "Bucket");
|
|
87
112
|
let bucketStem = Stdlib_Option.mapOr(bucketSpec.bucketName, "", pascalCase);
|
|
88
|
-
let name = extra$1
|
|
113
|
+
let name = bucketResourceName(ResourceAttribution$ReventlessCore.current.contents.plugin, extra$1, bucketSpec.bucketName);
|
|
89
114
|
let bucket = TaskBucket.make(name, opts);
|
|
90
115
|
let opts_parent = bucket.parts;
|
|
91
116
|
let opts$1 = {
|
|
@@ -112,9 +137,16 @@ function Make(Spec) {
|
|
|
112
137
|
}))))));
|
|
113
138
|
}
|
|
114
139
|
|
|
140
|
+
let maxBucketNameLength = 55;
|
|
141
|
+
|
|
115
142
|
export {
|
|
116
143
|
log,
|
|
117
144
|
pascalCase,
|
|
145
|
+
humpBoundary,
|
|
146
|
+
acronymBoundary,
|
|
147
|
+
kebabCase,
|
|
148
|
+
maxBucketNameLength,
|
|
149
|
+
bucketResourceName,
|
|
118
150
|
Make,
|
|
119
151
|
}
|
|
120
152
|
/* log Not a pure module */
|
|
@@ -510,11 +510,14 @@ let make = (
|
|
|
510
510
|
// The annotation as its author wrote it. `target.plugin` is `None`
|
|
511
511
|
// exactly when the field left the store unqualified, so this is the
|
|
512
512
|
// source text rather than a guess at it — and this is the only place
|
|
513
|
-
// that distinction survives.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
513
|
+
// that distinction survives. Always `Some` here: the field is optional
|
|
514
|
+
// on the type only so definitions stored before it existed still decode.
|
|
515
|
+
annotation: Some(
|
|
516
|
+
switch target.plugin {
|
|
517
|
+
| None => target.store
|
|
518
|
+
| Some(plugin) => plugin ++ "." ++ target.store
|
|
519
|
+
},
|
|
520
|
+
),
|
|
518
521
|
})
|
|
519
522
|
)
|
|
520
523
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Two declared object stores a platform cannot keep apart.
|
|
2
|
+
//
|
|
3
|
+
// Lives in core, not in an adapter: the rule is about a namespace, not about a
|
|
4
|
+
// cloud. The deployed platform enforces it as a gate before it provisions
|
|
5
|
+
// anything, and the local platform reports it at composition — one predicate, so
|
|
6
|
+
// the two cannot drift into disagreeing about what a collision is.
|
|
7
|
+
|
|
8
|
+
/** One declared store as the collision check sees it: its qualified key and the
|
|
9
|
+
prefix its objects are rooted at.
|
|
10
|
+
|
|
11
|
+
`site` is the `{component}.{field}` that declared it, when the caller has it.
|
|
12
|
+
The deploy-time capability list does not — `Platform.capability` carries only
|
|
13
|
+
`{plugin, store}`, and the generator renders provenance as comments in the
|
|
14
|
+
platform's generated capability file — so the deploy message points a reader
|
|
15
|
+
there instead of inventing a site it cannot know. */
|
|
16
|
+
type declaredStore = {
|
|
17
|
+
qualified: string,
|
|
18
|
+
prefix: string,
|
|
19
|
+
site?: string,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
Two stores a platform cannot keep apart.
|
|
24
|
+
|
|
25
|
+
The prefix is a **platform-global** namespace, not a per-bucket one: exactly one
|
|
26
|
+
distribution fronts every store bucket, and it takes one cache behavior per
|
|
27
|
+
served prefix. So two stores on one prefix are unroutable in either layout — a
|
|
28
|
+
shared bucket lists the prefix twice, a per-store layout lists it once per
|
|
29
|
+
bucket, and CloudFront accepts neither. Left to the deploy, that surfaces as an
|
|
30
|
+
AWS error naming a path pattern rather than the two plugins that caused it,
|
|
31
|
+
minutes in, after the silent damage below has already been provisioned:
|
|
32
|
+
intermixed objects, and a presign grant scoped by prefix that reaches into both
|
|
33
|
+
stores at once.
|
|
34
|
+
|
|
35
|
+
Compares **prefixes, not store names**, and on **containment, not equality**:
|
|
36
|
+
`a` collides with `a/b`, because a store rooted at `a` encloses one rooted at
|
|
37
|
+
`a/b` for serving, for IAM and for wiping alike. Both choices are what let a
|
|
38
|
+
qualified prefix scheme relax this check without rewriting it — a qualified
|
|
39
|
+
prefix simply stops colliding.
|
|
40
|
+
*/
|
|
41
|
+
type collision = {
|
|
42
|
+
first: declaredStore,
|
|
43
|
+
second: declaredStore,
|
|
44
|
+
/** True when the first prefix encloses the second rather than equalling it. */
|
|
45
|
+
nested: bool,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let collisionsFor = (~stores: array<declaredStore>): array<collision> =>
|
|
49
|
+
stores->Array.reduceWithIndex([], (found, a, i) =>
|
|
50
|
+
stores->Array.reduceWithIndex(found, (found, b, j) =>
|
|
51
|
+
if j <= i || a.qualified == b.qualified {
|
|
52
|
+
found
|
|
53
|
+
} else if a.prefix == b.prefix {
|
|
54
|
+
Array.concat(found, [{first: a, second: b, nested: false}])
|
|
55
|
+
} else if b.prefix->String.startsWith(a.prefix ++ "/") {
|
|
56
|
+
Array.concat(found, [{first: a, second: b, nested: true}])
|
|
57
|
+
} else if a.prefix->String.startsWith(b.prefix ++ "/") {
|
|
58
|
+
Array.concat(found, [{first: b, second: a, nested: true}])
|
|
59
|
+
} else {
|
|
60
|
+
found
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
/** The refusal text. Names both declaration sites, because "two stores collide"
|
|
66
|
+
without them sends a reader hunting through every schema in the platform —
|
|
67
|
+
and states both remedies, because a cross-plugin store is legitimate when the
|
|
68
|
+
`@storageRef` annotation qualifies it, and only accidental when it does not. */
|
|
69
|
+
let collisionMessage = (c: collision): string => {
|
|
70
|
+
let site = (s: declaredStore) =>
|
|
71
|
+
switch s.site {
|
|
72
|
+
| Some(site) => ` ${s.qualified} — declared at ${site}\n`
|
|
73
|
+
| None => ""
|
|
74
|
+
}
|
|
75
|
+
let sites = site(c.first) ++ site(c.second)
|
|
76
|
+
let where = sites == ""
|
|
77
|
+
? ` Both keys appear in the platform's generated capability file, which records each ` ++
|
|
78
|
+
`declaring component and field as a comment.\n`
|
|
79
|
+
: sites
|
|
80
|
+
if c.nested {
|
|
81
|
+
`Object store "${c.first.qualified}" is rooted at "${c.first.prefix}/", which encloses ` ++
|
|
82
|
+
`"${c.second.qualified}" at "${c.second.prefix}/".\n` ++
|
|
83
|
+
where ++
|
|
84
|
+
` One store's objects would sit inside the other's — for serving, for upload grants and ` ++
|
|
85
|
+
`for a store wipe alike. Rename one of them.`
|
|
86
|
+
} else {
|
|
87
|
+
`Object stores "${c.first.qualified}" and "${c.second.qualified}" both root their objects ` ++
|
|
88
|
+
`at "${c.first.prefix}/".\n` ++
|
|
89
|
+
where ++
|
|
90
|
+
` A platform serves one cache behavior per prefix and scopes upload grants by prefix, so ` ++
|
|
91
|
+
`two stores cannot share one.\n` ++
|
|
92
|
+
` Rename one store, or — if they were meant to be one shared store — qualify the ` ++
|
|
93
|
+
`\`@storageRef\` annotation with the owning plugin.`
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
|
+
|
|
5
|
+
function collisionsFor(stores) {
|
|
6
|
+
return Stdlib_Array.reduceWithIndex(stores, [], (found, a, i) => Stdlib_Array.reduceWithIndex(stores, found, (found, b, j) => {
|
|
7
|
+
if (j <= i || a.qualified === b.qualified) {
|
|
8
|
+
return found;
|
|
9
|
+
} else if (a.prefix === b.prefix) {
|
|
10
|
+
return found.concat([{
|
|
11
|
+
first: a,
|
|
12
|
+
second: b,
|
|
13
|
+
nested: false
|
|
14
|
+
}]);
|
|
15
|
+
} else if (b.prefix.startsWith(a.prefix + "/")) {
|
|
16
|
+
return found.concat([{
|
|
17
|
+
first: a,
|
|
18
|
+
second: b,
|
|
19
|
+
nested: true
|
|
20
|
+
}]);
|
|
21
|
+
} else if (a.prefix.startsWith(b.prefix + "/")) {
|
|
22
|
+
return found.concat([{
|
|
23
|
+
first: b,
|
|
24
|
+
second: a,
|
|
25
|
+
nested: true
|
|
26
|
+
}]);
|
|
27
|
+
} else {
|
|
28
|
+
return found;
|
|
29
|
+
}
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collisionMessage(c) {
|
|
34
|
+
let site = s => {
|
|
35
|
+
let site$1 = s.site;
|
|
36
|
+
if (site$1 !== undefined) {
|
|
37
|
+
return ` ` + s.qualified + ` — declared at ` + site$1 + `\n`;
|
|
38
|
+
} else {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
let sites = site(c.first) + site(c.second);
|
|
43
|
+
let where = sites === "" ? ` Both keys appear in the platform's generated capability file, which records each declaring component and field as a comment.\n` : sites;
|
|
44
|
+
if (c.nested) {
|
|
45
|
+
return `Object store "` + c.first.qualified + `" is rooted at "` + c.first.prefix + `/", which encloses ` + (`"` + c.second.qualified + `" at "` + c.second.prefix + `/".\n`) + where + ` One store's objects would sit inside the other's — for serving, for upload grants and for a store wipe alike. Rename one of them.`;
|
|
46
|
+
} else {
|
|
47
|
+
return `Object stores "` + c.first.qualified + `" and "` + c.second.qualified + `" both root their objects ` + (`at "` + c.first.prefix + `/".\n`) + where + ` A platform serves one cache behavior per prefix and scopes upload grants by prefix, so two stores cannot share one.\n Rename one store, or — if they were meant to be one shared store — qualify the \`@storageRef\` annotation with the owning plugin.`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
collisionsFor,
|
|
53
|
+
collisionMessage,
|
|
54
|
+
}
|
|
55
|
+
/* No side effect */
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The rule these guard is that a store's key prefix is a PLATFORM-global
|
|
4
|
+
// namespace: one distribution fronts every store bucket and takes one cache
|
|
5
|
+
// behavior per prefix, so two stores on one prefix are unroutable whatever the
|
|
6
|
+
// bucket layout. Left to the deploy that surfaces as a CloudFront error about a
|
|
7
|
+
// path pattern, minutes in and naming neither plugin.
|
|
8
|
+
|
|
9
|
+
module Collision = StorePrefixCollision
|
|
10
|
+
|
|
11
|
+
let store = (~qualified, ~prefix, ~site=?): Collision.declaredStore => {
|
|
12
|
+
qualified,
|
|
13
|
+
prefix,
|
|
14
|
+
?site,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("collisionsFor", () => {
|
|
18
|
+
testSync("distinct prefixes do not collide", () =>
|
|
19
|
+
expect(
|
|
20
|
+
Collision.collisionsFor(
|
|
21
|
+
~stores=[
|
|
22
|
+
store(~qualified="Catalog.productImages", ~prefix="productImages"),
|
|
23
|
+
store(~qualified="Ordering.labels", ~prefix="labels"),
|
|
24
|
+
],
|
|
25
|
+
)->Array.length,
|
|
26
|
+
)->toBe(0)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
testSync("two plugins declaring one store name collide", () =>
|
|
30
|
+
switch Collision.collisionsFor(
|
|
31
|
+
~stores=[
|
|
32
|
+
store(~qualified="Catalog.productImages", ~prefix="productImages"),
|
|
33
|
+
store(~qualified="Ordering.productImages", ~prefix="productImages"),
|
|
34
|
+
],
|
|
35
|
+
) {
|
|
36
|
+
| [c] => expect((c.first.qualified, c.second.qualified, c.nested))->toEqual((
|
|
37
|
+
"Catalog.productImages",
|
|
38
|
+
"Ordering.productImages",
|
|
39
|
+
false,
|
|
40
|
+
))
|
|
41
|
+
| other => fail(`expected exactly one collision, got ${other->Array.length->Int.toString}`)
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// Deliberate cross-plugin sharing resolves to ONE qualified key upstream, so
|
|
46
|
+
// it never reaches this predicate as two stores. Sharing is legitimate; only
|
|
47
|
+
// two independent claims on one prefix are not.
|
|
48
|
+
testSync("one store referenced by several plugins is a single entry, not a collision", () =>
|
|
49
|
+
expect(
|
|
50
|
+
Collision.collisionsFor(
|
|
51
|
+
~stores=[store(~qualified="Catalog.productImages", ~prefix="productImages")],
|
|
52
|
+
)->Array.length,
|
|
53
|
+
)->toBe(0)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Containment, not equality: a store rooted at `a` encloses one at `a/b` for
|
|
57
|
+
// serving, for upload grants and for a store wipe alike.
|
|
58
|
+
testSync("an enclosing prefix collides with what it encloses", () =>
|
|
59
|
+
switch Collision.collisionsFor(
|
|
60
|
+
~stores=[
|
|
61
|
+
store(~qualified="Catalog.productImages", ~prefix="Catalog/productImages"),
|
|
62
|
+
store(~qualified="Legacy.catalog", ~prefix="Catalog"),
|
|
63
|
+
],
|
|
64
|
+
) {
|
|
65
|
+
| [c] =>
|
|
66
|
+
// The enclosing store is reported first whichever order they arrive in.
|
|
67
|
+
expect((c.first.qualified, c.nested))->toEqual(("Legacy.catalog", true))
|
|
68
|
+
| other => fail(`expected exactly one collision, got ${other->Array.length->Int.toString}`)
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// A shared character run is not nesting — the delete/serve boundary is a path
|
|
73
|
+
// segment, so `images` does not reach `images-archive`.
|
|
74
|
+
testSync("a sibling prefix sharing a character run does not collide", () =>
|
|
75
|
+
expect(
|
|
76
|
+
Collision.collisionsFor(
|
|
77
|
+
~stores=[
|
|
78
|
+
store(~qualified="Catalog.images", ~prefix="images"),
|
|
79
|
+
store(~qualified="Catalog.imagesArchive", ~prefix="images-archive"),
|
|
80
|
+
],
|
|
81
|
+
)->Array.length,
|
|
82
|
+
)->toBe(0)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// This is the property that lets a plugin-qualified prefix scheme relax the
|
|
86
|
+
// rule without the predicate changing: qualified prefixes simply stop
|
|
87
|
+
// colliding, so "unique per platform" narrows to "unique per plugin".
|
|
88
|
+
testSync("qualifying the prefix with the plugin removes the collision", () =>
|
|
89
|
+
expect(
|
|
90
|
+
Collision.collisionsFor(
|
|
91
|
+
~stores=[
|
|
92
|
+
store(~qualified="Catalog.productImages", ~prefix="Catalog/productImages"),
|
|
93
|
+
store(~qualified="Ordering.productImages", ~prefix="Ordering/productImages"),
|
|
94
|
+
],
|
|
95
|
+
)->Array.length,
|
|
96
|
+
)->toBe(0)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
testSync("each colliding pair is reported once, not twice", () =>
|
|
100
|
+
expect(
|
|
101
|
+
Collision.collisionsFor(
|
|
102
|
+
~stores=[
|
|
103
|
+
store(~qualified="A.images", ~prefix="images"),
|
|
104
|
+
store(~qualified="B.images", ~prefix="images"),
|
|
105
|
+
store(~qualified="C.images", ~prefix="images"),
|
|
106
|
+
],
|
|
107
|
+
)->Array.length,
|
|
108
|
+
)->toBe(3)
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("collisionMessage", () => {
|
|
113
|
+
testSync("names both stores and both remedies", () => {
|
|
114
|
+
let message =
|
|
115
|
+
Collision.collisionsFor(
|
|
116
|
+
~stores=[
|
|
117
|
+
store(~qualified="Catalog.productImages", ~prefix="productImages"),
|
|
118
|
+
store(~qualified="Ordering.productImages", ~prefix="productImages"),
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
->Array.get(0)
|
|
122
|
+
->Option.mapOr("", Collision.collisionMessage)
|
|
123
|
+
expect(
|
|
124
|
+
message->String.includes("Catalog.productImages") &&
|
|
125
|
+
message->String.includes("Ordering.productImages") &&
|
|
126
|
+
message->String.includes("Rename one store") &&
|
|
127
|
+
message->String.includes("@storageRef"),
|
|
128
|
+
)->toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Without provenance the message must not invent a declaration site; it points
|
|
132
|
+
// at the file that does record one.
|
|
133
|
+
testSync("points at the capability file when it has no declaration site", () => {
|
|
134
|
+
let message =
|
|
135
|
+
Collision.collisionsFor(
|
|
136
|
+
~stores=[
|
|
137
|
+
store(~qualified="A.images", ~prefix="images"),
|
|
138
|
+
store(~qualified="B.images", ~prefix="images"),
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
->Array.get(0)
|
|
142
|
+
->Option.mapOr("", Collision.collisionMessage)
|
|
143
|
+
expect(message->String.includes("capability file"))->toBe(true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
testSync("uses the declaration site when the caller has one", () => {
|
|
147
|
+
let message =
|
|
148
|
+
Collision.collisionsFor(
|
|
149
|
+
~stores=[
|
|
150
|
+
store(~qualified="A.images", ~prefix="images", ~site="Product.imageUrl"),
|
|
151
|
+
store(~qualified="B.images", ~prefix="images", ~site="Shipment.imageUrl"),
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
->Array.get(0)
|
|
155
|
+
->Option.mapOr("", Collision.collisionMessage)
|
|
156
|
+
expect(
|
|
157
|
+
message->String.includes("Product.imageUrl") && message->String.includes("Shipment.imageUrl"),
|
|
158
|
+
)->toBe(true)
|
|
159
|
+
})
|
|
160
|
+
})
|