@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/package.json +6 -6
  3. package/src/admin/Platform_AdminApi.res +23 -0
  4. package/src/admin/Platform_AdminApi.res.mjs +12 -0
  5. package/src/components/Task/Task_Builder.res +75 -4
  6. package/src/components/Task/Task_Builder.res.mjs +33 -1
  7. package/src/plugin/component/Plugin_Structure.res +8 -5
  8. package/src/util/StorePrefixCollision.res +96 -0
  9. package/src/util/StorePrefixCollision.res.mjs +55 -0
  10. package/tests/StorePrefixCollisionTest.res +160 -0
  11. package/tests/StorePrefixCollisionTest.res.mjs +174 -0
  12. package/tests/TaskBucketNamingTest.res +97 -0
  13. package/tests/TaskBucketNamingTest.res.mjs +60 -0
  14. package/tests/api/SuryToJsonSchemaTest.res +46 -0
  15. package/tests/api/SuryToJsonSchemaTest.res.mjs +39 -0
  16. package/tests/fixtures/plugin-lifecycle/2026-07-22-versionconnected-no-requiredstores.json +1 -0
  17. package/tests/fixtures/plugin-lifecycle/2026-07-22-versionsuperseded-nested-definitions.json +1 -0
  18. package/tests/fixtures/plugin-lifecycle/2026-07-28-versionconnected-requiredstores-only.json +1 -0
  19. package/tests/fixtures/plugin-lifecycle/2026-07-30-versionconnected-declarations-without-annotation.json +1 -0
  20. package/tests/fixtures/plugin-lifecycle/2026-07-30-versiondisconnected-declarations-without-annotation.json +1 -0
  21. package/tests/fixtures/plugin-lifecycle/README.md +36 -0
  22. package/tests/plugin/PluginDefinitionRequiredScalarsTest.res +42 -0
  23. package/tests/plugin/PluginDefinitionRequiredScalarsTest.res.mjs +45 -0
  24. package/tests/plugin/PluginDefinitionScalars.res +78 -0
  25. package/tests/plugin/PluginDefinitionScalars.res.mjs +110 -0
  26. package/tests/plugin/PluginLifecycleCorpus.res +42 -0
  27. package/tests/plugin/PluginLifecycleCorpus.res.mjs +39 -0
  28. package/tests/plugin/PluginLifecycleCorpusTest.res +50 -0
  29. package/tests/plugin/PluginLifecycleCorpusTest.res.mjs +42 -0
  30. package/tests/plugin/PluginStructureTest.res +3 -3
  31. 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.198",
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-interop": "3.0.0-alpha.30",
40
- "@reventlessdev/reventless-infra": "3.0.0-alpha.115",
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 resource-name segment (empty for the
173
- // default unnamed bucket so the name stays `<Task>Bucket`, not
174
- // `<Task>BucketBucket`). `bucketName` above remains the runtime key.
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 = taskName ++ bucketStem ++ "Bucket"
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 + bucketStem + "Bucket";
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
- annotation: switch target.plugin {
515
- | None => target.store
516
- | Some(plugin) => plugin ++ "." ++ target.store
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
+ })