@reventlessdev/reventless-core 3.0.0-alpha.199 → 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 CHANGED
@@ -3,6 +3,29 @@
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
+
6
29
  # 3.0.0-alpha.199 (2026-08-01)
7
30
 
8
31
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.199",
3
+ "version": "3.0.0-alpha.200",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,15 +29,15 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
34
35
  "@reventlessdev/rescript-node": "2.0.0-alpha.0",
35
36
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.0",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
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-infra": "3.0.0-alpha.116",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.91",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.92",
41
41
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30"
42
42
  },
43
43
  "devDependencies": {
@@ -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 */
@@ -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
+ })
@@ -0,0 +1,174 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as JestGlobals from "@reventlessdev/rescript-jest/src/JestGlobals.res.mjs";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as StorePrefixCollision$ReventlessCore from "../src/util/StorePrefixCollision.res.mjs";
6
+
7
+ function store(qualified, prefix, site) {
8
+ return {
9
+ qualified: qualified,
10
+ prefix: prefix,
11
+ site: site
12
+ };
13
+ }
14
+
15
+ globalThis.describe("collisionsFor", () => {
16
+ globalThis.test("distinct prefixes do not collide", () => {
17
+ globalThis.expect(StorePrefixCollision$ReventlessCore.collisionsFor([
18
+ {
19
+ qualified: "Catalog.productImages",
20
+ prefix: "productImages"
21
+ },
22
+ {
23
+ qualified: "Ordering.labels",
24
+ prefix: "labels"
25
+ }
26
+ ]).length).toBe(0);
27
+ });
28
+ globalThis.test("two plugins declaring one store name collide", () => {
29
+ let other = StorePrefixCollision$ReventlessCore.collisionsFor([
30
+ {
31
+ qualified: "Catalog.productImages",
32
+ prefix: "productImages"
33
+ },
34
+ {
35
+ qualified: "Ordering.productImages",
36
+ prefix: "productImages"
37
+ }
38
+ ]);
39
+ if (other.length !== 1) {
40
+ return JestGlobals.fail(`expected exactly one collision, got ` + other.length.toString());
41
+ }
42
+ let c = other[0];
43
+ globalThis.expect([
44
+ c.first.qualified,
45
+ c.second.qualified,
46
+ c.nested
47
+ ]).toEqual([
48
+ "Catalog.productImages",
49
+ "Ordering.productImages",
50
+ false
51
+ ]);
52
+ });
53
+ globalThis.test("one store referenced by several plugins is a single entry, not a collision", () => {
54
+ globalThis.expect(StorePrefixCollision$ReventlessCore.collisionsFor([{
55
+ qualified: "Catalog.productImages",
56
+ prefix: "productImages"
57
+ }]).length).toBe(0);
58
+ });
59
+ globalThis.test("an enclosing prefix collides with what it encloses", () => {
60
+ let other = StorePrefixCollision$ReventlessCore.collisionsFor([
61
+ {
62
+ qualified: "Catalog.productImages",
63
+ prefix: "Catalog/productImages"
64
+ },
65
+ {
66
+ qualified: "Legacy.catalog",
67
+ prefix: "Catalog"
68
+ }
69
+ ]);
70
+ if (other.length !== 1) {
71
+ return JestGlobals.fail(`expected exactly one collision, got ` + other.length.toString());
72
+ }
73
+ let c = other[0];
74
+ globalThis.expect([
75
+ c.first.qualified,
76
+ c.nested
77
+ ]).toEqual([
78
+ "Legacy.catalog",
79
+ true
80
+ ]);
81
+ });
82
+ globalThis.test("a sibling prefix sharing a character run does not collide", () => {
83
+ globalThis.expect(StorePrefixCollision$ReventlessCore.collisionsFor([
84
+ {
85
+ qualified: "Catalog.images",
86
+ prefix: "images"
87
+ },
88
+ {
89
+ qualified: "Catalog.imagesArchive",
90
+ prefix: "images-archive"
91
+ }
92
+ ]).length).toBe(0);
93
+ });
94
+ globalThis.test("qualifying the prefix with the plugin removes the collision", () => {
95
+ globalThis.expect(StorePrefixCollision$ReventlessCore.collisionsFor([
96
+ {
97
+ qualified: "Catalog.productImages",
98
+ prefix: "Catalog/productImages"
99
+ },
100
+ {
101
+ qualified: "Ordering.productImages",
102
+ prefix: "Ordering/productImages"
103
+ }
104
+ ]).length).toBe(0);
105
+ });
106
+ globalThis.test("each colliding pair is reported once, not twice", () => {
107
+ globalThis.expect(StorePrefixCollision$ReventlessCore.collisionsFor([
108
+ {
109
+ qualified: "A.images",
110
+ prefix: "images"
111
+ },
112
+ {
113
+ qualified: "B.images",
114
+ prefix: "images"
115
+ },
116
+ {
117
+ qualified: "C.images",
118
+ prefix: "images"
119
+ }
120
+ ]).length).toBe(3);
121
+ });
122
+ });
123
+
124
+ globalThis.describe("collisionMessage", () => {
125
+ globalThis.test("names both stores and both remedies", () => {
126
+ let message = Stdlib_Option.mapOr(StorePrefixCollision$ReventlessCore.collisionsFor([
127
+ {
128
+ qualified: "Catalog.productImages",
129
+ prefix: "productImages"
130
+ },
131
+ {
132
+ qualified: "Ordering.productImages",
133
+ prefix: "productImages"
134
+ }
135
+ ])[0], "", StorePrefixCollision$ReventlessCore.collisionMessage);
136
+ globalThis.expect(message.includes("Catalog.productImages") && message.includes("Ordering.productImages") && message.includes("Rename one store") && message.includes("@storageRef")).toBe(true);
137
+ });
138
+ globalThis.test("points at the capability file when it has no declaration site", () => {
139
+ let message = Stdlib_Option.mapOr(StorePrefixCollision$ReventlessCore.collisionsFor([
140
+ {
141
+ qualified: "A.images",
142
+ prefix: "images"
143
+ },
144
+ {
145
+ qualified: "B.images",
146
+ prefix: "images"
147
+ }
148
+ ])[0], "", StorePrefixCollision$ReventlessCore.collisionMessage);
149
+ globalThis.expect(message.includes("capability file")).toBe(true);
150
+ });
151
+ globalThis.test("uses the declaration site when the caller has one", () => {
152
+ let message = Stdlib_Option.mapOr(StorePrefixCollision$ReventlessCore.collisionsFor([
153
+ {
154
+ qualified: "A.images",
155
+ prefix: "images",
156
+ site: "Product.imageUrl"
157
+ },
158
+ {
159
+ qualified: "B.images",
160
+ prefix: "images",
161
+ site: "Shipment.imageUrl"
162
+ }
163
+ ])[0], "", StorePrefixCollision$ReventlessCore.collisionMessage);
164
+ globalThis.expect(message.includes("Product.imageUrl") && message.includes("Shipment.imageUrl")).toBe(true);
165
+ });
166
+ });
167
+
168
+ let Collision;
169
+
170
+ export {
171
+ Collision,
172
+ store,
173
+ }
174
+ /* Not a pure module */
@@ -0,0 +1,97 @@
1
+ open JestGlobals
2
+
3
+ // A task bucket's name is emitted into S3, which lowercases it — so the rule
4
+ // these guard is that word boundaries have to survive that lowercasing, and that
5
+ // the name says which plugin's task owns the bucket. The runtime lookup key
6
+ // (`Task.bucketNames`) is a separate string and is deliberately untouched.
7
+
8
+ module Naming = Task_Builder
9
+
10
+ describe("kebabCase", () => {
11
+ testSync("splits PascalCase humps", () =>
12
+ expect(Naming.kebabCase("ImportProducts"))->toBe("import-products")
13
+ )
14
+
15
+ testSync("splits camelCase humps", () =>
16
+ expect(Naming.kebabCase("productImages"))->toBe("product-images")
17
+ )
18
+
19
+ testSync("keeps an acronym together but splits the word after it", () =>
20
+ expect(Naming.kebabCase("HTTPServerLog"))->toBe("http-server-log")
21
+ )
22
+
23
+ testSync("normalises kebab and snake input to one shape", () => {
24
+ expect(Naming.kebabCase("product-imports"))->toBe("product-imports")
25
+ expect(Naming.kebabCase("product_imports"))->toBe("product-imports")
26
+ })
27
+
28
+ testSync("drops empty segments rather than emitting a double dash", () =>
29
+ expect(Naming.kebabCase("product--imports_"))->toBe("product-imports")
30
+ )
31
+
32
+ testSync("is idempotent", () =>
33
+ expect(Naming.kebabCase(Naming.kebabCase("ImportProducts")))->toBe("import-products")
34
+ )
35
+ })
36
+
37
+ describe("bucketResourceName", () => {
38
+ testSync("qualifies a task's default bucket with its plugin", () =>
39
+ expect(Naming.bucketResourceName(~plugin=Some("Catalog"), ~task="ImportProducts", ~bucketId=None))
40
+ ->toBe("catalog-import-products")
41
+ )
42
+
43
+ testSync("appends a declared bucket id", () =>
44
+ expect(
45
+ Naming.bucketResourceName(
46
+ ~plugin=Some("Catalog"),
47
+ ~task="ImportProducts",
48
+ ~bucketId=Some("product-imports"),
49
+ ),
50
+ )->toBe("catalog-import-products-product-imports")
51
+ )
52
+
53
+ // A task built outside any plugin has no ambient plugin to qualify with; the
54
+ // name stays valid rather than growing an empty leading segment.
55
+ testSync("omits the plugin segment when there is no ambient plugin", () =>
56
+ expect(Naming.bucketResourceName(~plugin=None, ~task="ImportProducts", ~bucketId=None))->toBe(
57
+ "import-products",
58
+ )
59
+ )
60
+
61
+ // `aws:s3/bucket` and the `reventless:role=Bucket` tag already say what it is.
62
+ testSync("carries no Bucket suffix", () =>
63
+ expect(
64
+ Naming.bucketResourceName(
65
+ ~plugin=Some("Catalog"),
66
+ ~task="ImportProducts",
67
+ ~bucketId=None,
68
+ )->String.endsWith("bucket"),
69
+ )->toBe(false)
70
+ )
71
+
72
+ testSync("fits S3's limit once Pulumi's uniqueness suffix is added", () =>
73
+ expect(
74
+ Naming.bucketResourceName(
75
+ ~plugin=Some("Catalog"),
76
+ ~task="ImportProducts",
77
+ ~bucketId=Some("product-imports"),
78
+ )->String.length <= Naming.maxBucketNameLength,
79
+ )->toBe(true)
80
+ )
81
+
82
+ // Truncating would silently collide two long names that share a prefix, so an
83
+ // over-long name is the declaration's to fix and the error has to name it.
84
+ testSync("throws rather than truncating an over-long name", () => {
85
+ let threw = try {
86
+ let _ = Naming.bucketResourceName(
87
+ ~plugin=Some("SomeRatherLongPluginName"),
88
+ ~task="AnEquallyLongRunningTaskName",
89
+ ~bucketId=Some("with-a-verbose-bucket-identifier"),
90
+ )
91
+ false
92
+ } catch {
93
+ | _ => true
94
+ }
95
+ expect(threw)->toBe(true)
96
+ })
97
+ })
@@ -0,0 +1,60 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Task_Builder$ReventlessCore from "../src/components/Task/Task_Builder.res.mjs";
4
+
5
+ globalThis.describe("kebabCase", () => {
6
+ globalThis.test("splits PascalCase humps", () => {
7
+ globalThis.expect(Task_Builder$ReventlessCore.kebabCase("ImportProducts")).toBe("import-products");
8
+ });
9
+ globalThis.test("splits camelCase humps", () => {
10
+ globalThis.expect(Task_Builder$ReventlessCore.kebabCase("productImages")).toBe("product-images");
11
+ });
12
+ globalThis.test("keeps an acronym together but splits the word after it", () => {
13
+ globalThis.expect(Task_Builder$ReventlessCore.kebabCase("HTTPServerLog")).toBe("http-server-log");
14
+ });
15
+ globalThis.test("normalises kebab and snake input to one shape", () => {
16
+ globalThis.expect(Task_Builder$ReventlessCore.kebabCase("product-imports")).toBe("product-imports");
17
+ globalThis.expect(Task_Builder$ReventlessCore.kebabCase("product_imports")).toBe("product-imports");
18
+ });
19
+ globalThis.test("drops empty segments rather than emitting a double dash", () => {
20
+ globalThis.expect(Task_Builder$ReventlessCore.kebabCase("product--imports_")).toBe("product-imports");
21
+ });
22
+ globalThis.test("is idempotent", () => {
23
+ globalThis.expect(Task_Builder$ReventlessCore.kebabCase(Task_Builder$ReventlessCore.kebabCase("ImportProducts"))).toBe("import-products");
24
+ });
25
+ });
26
+
27
+ globalThis.describe("bucketResourceName", () => {
28
+ globalThis.test("qualifies a task's default bucket with its plugin", () => {
29
+ globalThis.expect(Task_Builder$ReventlessCore.bucketResourceName("Catalog", "ImportProducts", undefined)).toBe("catalog-import-products");
30
+ });
31
+ globalThis.test("appends a declared bucket id", () => {
32
+ globalThis.expect(Task_Builder$ReventlessCore.bucketResourceName("Catalog", "ImportProducts", "product-imports")).toBe("catalog-import-products-product-imports");
33
+ });
34
+ globalThis.test("omits the plugin segment when there is no ambient plugin", () => {
35
+ globalThis.expect(Task_Builder$ReventlessCore.bucketResourceName(undefined, "ImportProducts", undefined)).toBe("import-products");
36
+ });
37
+ globalThis.test("carries no Bucket suffix", () => {
38
+ globalThis.expect(Task_Builder$ReventlessCore.bucketResourceName("Catalog", "ImportProducts", undefined).endsWith("bucket")).toBe(false);
39
+ });
40
+ globalThis.test("fits S3's limit once Pulumi's uniqueness suffix is added", () => {
41
+ globalThis.expect(Task_Builder$ReventlessCore.bucketResourceName("Catalog", "ImportProducts", "product-imports").length <= Task_Builder$ReventlessCore.maxBucketNameLength).toBe(true);
42
+ });
43
+ globalThis.test("throws rather than truncating an over-long name", () => {
44
+ let threw;
45
+ try {
46
+ Task_Builder$ReventlessCore.bucketResourceName("SomeRatherLongPluginName", "AnEquallyLongRunningTaskName", "with-a-verbose-bucket-identifier");
47
+ threw = false;
48
+ } catch (exn) {
49
+ threw = true;
50
+ }
51
+ globalThis.expect(threw).toBe(true);
52
+ });
53
+ });
54
+
55
+ let Naming;
56
+
57
+ export {
58
+ Naming,
59
+ }
60
+ /* Not a pure module */
@@ -794,4 +794,50 @@ describe("SuryToJsonSchema:", () => {
794
794
  ))
795
795
  )
796
796
  })
797
+
798
+ // The third composite. Its emission matters more than the other two's, because
799
+ // the reader has been resolving this shape from a *heuristic* since before the
800
+ // marker existed: an object with numeric `lat`/`lng` sub-properties already
801
+ // resolves to the geo-point semantic by shape. So the assertions below are what
802
+ // separate a declaration from a lucky guess — the marker and its `type` source.
803
+ describe("GeoPoint is an object carrying the geoPoint marker:", () => {
804
+ let json = SuryToJsonSchema.deriveObjectSchema(
805
+ S.schema(s =>
806
+ {
807
+ "location": s.matches(Reventless.GeoPoint.schema),
808
+ "lat": s.matches(S.float),
809
+ }
810
+ )->S.castToUnknown,
811
+ )
812
+ let keyOf = (name, key) => getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, key))
813
+
814
+ testSync("the field is an object, not a pair of numbers", () =>
815
+ expect(keyOf("location", "type"))->toEqual(Some(JSON.Encode.string("object")))
816
+ )
817
+
818
+ testSync("it carries the geoPoint id, sourced from the type", () =>
819
+ expect((
820
+ keyOf("location", "x-reventless-semantic"),
821
+ keyOf("location", "x-reventless-semantic-source"),
822
+ ))->toEqual((Some(JSON.Encode.string("geoPoint")), Some(JSON.Encode.string("type"))))
823
+ )
824
+
825
+ // The control, and the whole reason the composite exists: a bare `lat` float
826
+ // is exactly what the flattened pair looked like, and it emits nothing at
827
+ // all. Everything a map knew about it, it inferred from the name.
828
+ testSync("a bare lat float emits no semantic", () =>
829
+ expect((keyOf("lat", "type"), keyOf("lat", "x-reventless-semantic")))->toEqual((
830
+ Some(JSON.Encode.string("number")),
831
+ None,
832
+ ))
833
+ )
834
+
835
+ testSync("both coordinates reach the wire as numbers", () => {
836
+ let inner = getPropertyOf(json, "location")->Option.getOr(JSON.Encode.null)
837
+ expect((
838
+ getPropertyOf(inner, "lat")->Option.flatMap(s => getProperty(s, "type")),
839
+ getPropertyOf(inner, "lng")->Option.flatMap(s => getProperty(s, "type")),
840
+ ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("number"))))
841
+ })
842
+ })
797
843
  })
@@ -11,6 +11,7 @@ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/component
11
11
  import * as Percent$Reventless from "@reventlessdev/reventless-spec/src/semantic/Percent.res.mjs";
12
12
  import * as Currency$Reventless from "@reventlessdev/reventless-spec/src/semantic/Currency.res.mjs";
13
13
  import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
14
+ import * as GeoPoint$Reventless from "@reventlessdev/reventless-spec/src/semantic/GeoPoint.res.mjs";
14
15
  import * as DateRange$Reventless from "@reventlessdev/reventless-spec/src/semantic/DateRange.res.mjs";
15
16
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
16
17
  import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
@@ -1074,6 +1075,44 @@ globalThis.describe("SuryToJsonSchema:", () => {
1074
1075
  ]);
1075
1076
  });
1076
1077
  });
1078
+ globalThis.describe("GeoPoint is an object carrying the geoPoint marker:", () => {
1079
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(S.schema(s => ({
1080
+ location: s.m(GeoPoint$Reventless.schema),
1081
+ lat: s.m(S.float)
1082
+ })));
1083
+ let keyOf = (name, key) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, key));
1084
+ globalThis.test("the field is an object, not a pair of numbers", () => {
1085
+ globalThis.expect(keyOf("location", "type")).toEqual("object");
1086
+ });
1087
+ globalThis.test("it carries the geoPoint id, sourced from the type", () => {
1088
+ globalThis.expect([
1089
+ keyOf("location", "x-reventless-semantic"),
1090
+ keyOf("location", "x-reventless-semantic-source")
1091
+ ]).toEqual([
1092
+ "geoPoint",
1093
+ "type"
1094
+ ]);
1095
+ });
1096
+ globalThis.test("a bare lat float emits no semantic", () => {
1097
+ globalThis.expect([
1098
+ keyOf("lat", "type"),
1099
+ keyOf("lat", "x-reventless-semantic")
1100
+ ]).toEqual([
1101
+ "number",
1102
+ undefined
1103
+ ]);
1104
+ });
1105
+ globalThis.test("both coordinates reach the wire as numbers", () => {
1106
+ let inner = Stdlib_Option.getOr(getPropertyOf(json, "location"), null);
1107
+ globalThis.expect([
1108
+ Stdlib_Option.flatMap(getPropertyOf(inner, "lat"), s => getProperty(s, "type")),
1109
+ Stdlib_Option.flatMap(getPropertyOf(inner, "lng"), s => getProperty(s, "type"))
1110
+ ]).toEqual([
1111
+ "number",
1112
+ "number"
1113
+ ]);
1114
+ });
1115
+ });
1077
1116
  });
1078
1117
 
1079
1118
  export {