@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 +23 -0
- package/package.json +5 -5
- package/src/components/Task/Task_Builder.res +75 -4
- package/src/components/Task/Task_Builder.res.mjs +33 -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/SuryToJsonSchemaTest.res +46 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +39 -0
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.
|
|
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-
|
|
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
|
|
173
|
-
// default unnamed bucket
|
|
174
|
-
//
|
|
237
|
+
// `bucketStem` is the PascalCase segment naming this bucket's Lambda
|
|
238
|
+
// (empty for the default unnamed bucket). `bucketName` above remains the
|
|
239
|
+
// runtime key into `Task.bucketNames`; `name` is the bucket's own
|
|
240
|
+
// resource name, kebab-cased and plugin-qualified because S3 lowercases
|
|
241
|
+
// it — see `bucketResourceName`.
|
|
175
242
|
let bucketStem = bucketSpec.bucketName->Option.mapOr("", pascalCase)
|
|
176
|
-
let name =
|
|
243
|
+
let name = bucketResourceName(
|
|
244
|
+
~plugin=ResourceAttribution.current.contents.plugin,
|
|
245
|
+
~task=taskName,
|
|
246
|
+
~bucketId=bucketSpec.bucketName,
|
|
247
|
+
)
|
|
177
248
|
let bucket = TaskBucket.make(~name, ~opts)
|
|
178
249
|
let opts = {Pulumi.ComponentResource.parent: bucket.parts->Pulumi.Resource.makeFromJs}
|
|
179
250
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
5
|
import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
|
|
5
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
7
|
import * as Pulumi from "@pulumi/pulumi";
|
|
8
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
7
9
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
8
10
|
import * as Task$ReventlessCore from "./Task.res.mjs";
|
|
9
11
|
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
@@ -13,6 +15,7 @@ import * as Util_Promise$ReventlessCore from "../../util/Util_Promise.res.mjs";
|
|
|
13
15
|
import * as ComponentType$ReventlessCore from "../../ComponentType.res.mjs";
|
|
14
16
|
import * as RuntimeHints$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/RuntimeHints.res.mjs";
|
|
15
17
|
import * as Builder_Helpers$ReventlessCore from "../Builder_Helpers.res.mjs";
|
|
18
|
+
import * as ResourceAttribution$ReventlessCore from "../../ResourceAttribution.res.mjs";
|
|
16
19
|
|
|
17
20
|
let log = Logger$ReventlessCore.fromEnv();
|
|
18
21
|
|
|
@@ -20,6 +23,28 @@ function pascalCase(s) {
|
|
|
20
23
|
return s.split("-").flatMap(p => p.split("_")).filter(p => p.length > 0).map(p => p.slice(0, 1).toUpperCase() + p.slice(1, p.length)).join("");
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
let humpBoundary = /([a-z0-9])([A-Z])/g;
|
|
27
|
+
|
|
28
|
+
let acronymBoundary = /([A-Z]+)([A-Z][a-z])/g;
|
|
29
|
+
|
|
30
|
+
function kebabCase(s) {
|
|
31
|
+
return s.replace(acronymBoundary, "$1-$2").replace(humpBoundary, "$1-$2").split("-").flatMap(p => p.split("_")).filter(p => p.length > 0).map(prim => prim.toLowerCase()).join("-");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bucketResourceName(plugin, task, bucketId) {
|
|
35
|
+
let name = Stdlib_Array.filterMap([
|
|
36
|
+
plugin,
|
|
37
|
+
task,
|
|
38
|
+
bucketId
|
|
39
|
+
], segment => segment).map(kebabCase).filter(segment => segment !== "").join("-");
|
|
40
|
+
if (name.length > 55) {
|
|
41
|
+
Stdlib_JsError.throwWithMessage(`Task bucket name "` + name + `" is ` + name.length.toString() + ` characters; S3 allows ` + (55).toString() + ` once Pulumi's uniqueness suffix is added. Shorten the task name` + (
|
|
42
|
+
bucketId !== undefined ? ` or the bucket id "` + bucketId + `".` : "."
|
|
43
|
+
));
|
|
44
|
+
}
|
|
45
|
+
return name;
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
function Make(Spec) {
|
|
24
49
|
return RuntimeEnvironment => (EventCollectorChannel => (EventCollectorRuntimeBuilder => (TaskRuntimeBuilder => (TaskBucket => (SpecificSideEffectHandler => (Defaults => {
|
|
25
50
|
let make = (queryBucketName, scheduler, schedulerRoleUrn, publishToAggregates, queryEngine, resourceNaming, allAggregates, runtime, opts) => Component$ReventlessCore.make(ComponentType$ReventlessCore.toString(Task$ReventlessCore.componentType), Spec.name, (extra, extra$1) => {
|
|
@@ -85,7 +110,7 @@ function Make(Spec) {
|
|
|
85
110
|
let bucketNames = Stdlib_Option.map(config.buckets, buckets => Object.fromEntries(buckets.map(bucketSpec => {
|
|
86
111
|
let bucketName = Stdlib_Option.getOr(bucketSpec.bucketName, "Bucket");
|
|
87
112
|
let bucketStem = Stdlib_Option.mapOr(bucketSpec.bucketName, "", pascalCase);
|
|
88
|
-
let name = extra$1
|
|
113
|
+
let name = bucketResourceName(ResourceAttribution$ReventlessCore.current.contents.plugin, extra$1, bucketSpec.bucketName);
|
|
89
114
|
let bucket = TaskBucket.make(name, opts);
|
|
90
115
|
let opts_parent = bucket.parts;
|
|
91
116
|
let opts$1 = {
|
|
@@ -112,9 +137,16 @@ function Make(Spec) {
|
|
|
112
137
|
}))))));
|
|
113
138
|
}
|
|
114
139
|
|
|
140
|
+
let maxBucketNameLength = 55;
|
|
141
|
+
|
|
115
142
|
export {
|
|
116
143
|
log,
|
|
117
144
|
pascalCase,
|
|
145
|
+
humpBoundary,
|
|
146
|
+
acronymBoundary,
|
|
147
|
+
kebabCase,
|
|
148
|
+
maxBucketNameLength,
|
|
149
|
+
bucketResourceName,
|
|
118
150
|
Make,
|
|
119
151
|
}
|
|
120
152
|
/* log Not a pure module */
|
|
@@ -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 {
|