@reventlessdev/reventless-core 3.0.0-alpha.199 → 3.0.0-alpha.201
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 +31 -0
- package/package.json +7 -7
- package/src/adapter/Runtime/Runtime.res +9 -1
- package/src/adapter/Runtime/Runtime.res.mjs +1 -2
- package/src/admin/Platform_Admin.res +1 -1
- package/src/admin/Platform_Admin.res.mjs +1 -0
- package/src/admin/Platform_AdminApi.res +1 -1
- package/src/components/Api/StorageRefFields.res +124 -0
- package/src/components/Api/StorageRefFields.res.mjs +88 -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_Builder.res +1 -1
- package/src/plugin/component/Plugin_Builder.res.mjs +1 -0
- package/src/plugin/component/Plugin_Helpers.res +5 -0
- package/src/plugin/component/Plugin_Structure.res +5 -1
- package/src/plugin/component/Plugin_Structure.res.mjs +2 -1
- 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/StorageRefFieldsTest.res +148 -0
- package/tests/api/StorageRefFieldsTest.res.mjs +152 -0
- package/tests/api/SuryToJsonSchemaTest.res +46 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +39 -0
|
@@ -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 */
|