@reventlessdev/reventless-seed-aws 1.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +202 -0
  3. package/lib/bs/.compiler.log +2 -0
  4. package/lib/bs/.sourcedirs.json +1 -0
  5. package/lib/bs/build.ninja +0 -0
  6. package/lib/bs/compiler-info.json +8 -0
  7. package/lib/bs/src/ReventlessSeedAws.ast +0 -0
  8. package/lib/bs/src/ReventlessSeedAws.cmi +0 -0
  9. package/lib/bs/src/ReventlessSeedAws.cmj +0 -0
  10. package/lib/bs/src/ReventlessSeedAws.cmt +0 -0
  11. package/lib/bs/src/ReventlessSeedAws.res +328 -0
  12. package/lib/bs/src/ReventlessSeedAws.res.mjs +309 -0
  13. package/lib/bs/src/ReventlessSeedAws_Reconcile.ast +0 -0
  14. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmi +0 -0
  15. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmj +0 -0
  16. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmt +0 -0
  17. package/lib/bs/src/ReventlessSeedAws_Reconcile.res +389 -0
  18. package/lib/bs/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
  19. package/lib/bs/src/ReventlessSeedAws_Reset.ast +0 -0
  20. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  21. package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
  22. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  23. package/lib/bs/src/ReventlessSeedAws_Reset.res +948 -0
  24. package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +861 -0
  25. package/lib/bs/tests/EndpointResolutionTest.ast +0 -0
  26. package/lib/bs/tests/EndpointResolutionTest.cmi +0 -0
  27. package/lib/bs/tests/EndpointResolutionTest.cmj +0 -0
  28. package/lib/bs/tests/EndpointResolutionTest.cmt +0 -0
  29. package/lib/bs/tests/EndpointResolutionTest.res +83 -0
  30. package/lib/bs/tests/EndpointResolutionTest.res.mjs +115 -0
  31. package/lib/bs/tests/ObjectStoreReconcileTest.ast +0 -0
  32. package/lib/bs/tests/ObjectStoreReconcileTest.cmi +0 -0
  33. package/lib/bs/tests/ObjectStoreReconcileTest.cmj +0 -0
  34. package/lib/bs/tests/ObjectStoreReconcileTest.cmt +0 -0
  35. package/lib/bs/tests/ObjectStoreReconcileTest.res +84 -0
  36. package/lib/bs/tests/ObjectStoreReconcileTest.res.mjs +102 -0
  37. package/lib/bs/tests/ObjectStoreResetTest.ast +0 -0
  38. package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
  39. package/lib/bs/tests/ObjectStoreResetTest.cmj +0 -0
  40. package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
  41. package/lib/bs/tests/ObjectStoreResetTest.res +170 -0
  42. package/lib/bs/tests/ObjectStoreResetTest.res.mjs +211 -0
  43. package/lib/ocaml/.compiler.log +2 -0
  44. package/lib/ocaml/EndpointResolutionTest.ast +0 -0
  45. package/lib/ocaml/EndpointResolutionTest.cmi +0 -0
  46. package/lib/ocaml/EndpointResolutionTest.cmj +0 -0
  47. package/lib/ocaml/EndpointResolutionTest.cmt +0 -0
  48. package/lib/ocaml/EndpointResolutionTest.res +83 -0
  49. package/lib/ocaml/ObjectStoreReconcileTest.ast +0 -0
  50. package/lib/ocaml/ObjectStoreReconcileTest.cmi +0 -0
  51. package/lib/ocaml/ObjectStoreReconcileTest.cmj +0 -0
  52. package/lib/ocaml/ObjectStoreReconcileTest.cmt +0 -0
  53. package/lib/ocaml/ObjectStoreReconcileTest.res +84 -0
  54. package/lib/ocaml/ObjectStoreResetTest.ast +0 -0
  55. package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
  56. package/lib/ocaml/ObjectStoreResetTest.cmj +0 -0
  57. package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
  58. package/lib/ocaml/ObjectStoreResetTest.res +170 -0
  59. package/lib/ocaml/ReventlessSeedAws.ast +0 -0
  60. package/lib/ocaml/ReventlessSeedAws.cmi +0 -0
  61. package/lib/ocaml/ReventlessSeedAws.cmj +0 -0
  62. package/lib/ocaml/ReventlessSeedAws.cmt +0 -0
  63. package/lib/ocaml/ReventlessSeedAws.res +328 -0
  64. package/lib/ocaml/ReventlessSeedAws_Reconcile.ast +0 -0
  65. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmi +0 -0
  66. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmj +0 -0
  67. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmt +0 -0
  68. package/lib/ocaml/ReventlessSeedAws_Reconcile.res +389 -0
  69. package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
  70. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  71. package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
  72. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  73. package/lib/ocaml/ReventlessSeedAws_Reset.res +948 -0
  74. package/package.json +32 -0
  75. package/rescript.json +29 -0
  76. package/src/ReventlessSeedAws.res +328 -0
  77. package/src/ReventlessSeedAws.res.mjs +309 -0
  78. package/src/ReventlessSeedAws_Reconcile.res +389 -0
  79. package/src/ReventlessSeedAws_Reconcile.res.mjs +318 -0
  80. package/src/ReventlessSeedAws_Reset.res +948 -0
  81. package/src/ReventlessSeedAws_Reset.res.mjs +861 -0
  82. package/tests/EndpointResolutionTest.res +83 -0
  83. package/tests/EndpointResolutionTest.res.mjs +115 -0
  84. package/tests/ObjectStoreReconcileTest.res +84 -0
  85. package/tests/ObjectStoreReconcileTest.res.mjs +102 -0
  86. package/tests/ObjectStoreResetTest.res +170 -0
  87. package/tests/ObjectStoreResetTest.res.mjs +211 -0
@@ -0,0 +1,83 @@
1
+ // Which document `resolveEndpoints` reads, and which GraphQL endpoint each arm takes
2
+ // from it.
3
+ //
4
+ // `endpointsFrom` is the pure half of `resolveEndpoints`; the impure half is a
5
+ // `pulumi stack output` subprocess and a `config.json` fetch, and the decision these
6
+ // tests pin is in neither. Upload endpoints are no longer resolved here — under route B
7
+ // the seed mints through the domain API's `Upload_Presign` mutation on the GraphQL
8
+ // endpoint below — so the only decision left is which GraphQL endpoint each source
9
+ // publishes, and the merged-over-per-plugin precedence within the stack-outputs arm.
10
+
11
+ open JestGlobals
12
+ open ReventlessSeed
13
+
14
+ // Every value read here has an env override, and an ambient one in the runner's
15
+ // environment (AWS_REGION is routinely set) would silently stand in for the document
16
+ // under test. An empty string reads as unset.
17
+ let clearOverrides = () =>
18
+ [
19
+ "REVENTLESS_GRAPHQL_ENDPOINT",
20
+ "AWS_REGION",
21
+ "COGNITO_CLIENT_ID",
22
+ "SEED_SKIP_UPLOADS",
23
+ ]->Array.forEach(k => NodeProcess.env->Dict.set(k, ""))
24
+
25
+ let obj = entries => JSON.Encode.object(entries->Dict.fromArray)
26
+ let str = JSON.Encode.string
27
+
28
+ let config = (~extra: array<(string, JSON.t)>) =>
29
+ obj(
30
+ Array.concat(
31
+ [
32
+ ("apiEndpoint", str("https://api.example/graphql")),
33
+ ("region", str("eu-west-1")),
34
+ ("cognitoClientId", str("client-abc")),
35
+ ],
36
+ extra,
37
+ ),
38
+ )
39
+
40
+ let outputs = (~extra: array<(string, JSON.t)>) =>
41
+ obj(
42
+ Array.concat(
43
+ [
44
+ ("domainApiEndpoint", str("https://domain.example/graphql")),
45
+ ("cognitoRegion", str("eu-west-1")),
46
+ ("cognitoUserPoolClientId", str("client-abc")),
47
+ ],
48
+ extra,
49
+ ),
50
+ )
51
+
52
+ describe("endpointsFrom — host shell arm", () => {
53
+ beforeEach(clearOverrides)
54
+
55
+ testSync("reads the GraphQL endpoint from the shell config", () => {
56
+ let eps = ReventlessSeedAws.endpointsFrom(~stack="alpha", HostShellConfig(config(~extra=[])))
57
+ expect(eps.graphql)->toBe("https://api.example/graphql")
58
+ })
59
+ })
60
+
61
+ describe("endpointsFrom — stack outputs arm", () => {
62
+ beforeEach(clearOverrides)
63
+
64
+ testSync("prefers the merged API endpoint over the per-plugin one", () => {
65
+ let eps = ReventlessSeedAws.endpointsFrom(
66
+ ~stack="pr-verify",
67
+ StackOutputs(outputs(~extra=[("domainMergedApiEndpoint", str("https://merged.example/"))])),
68
+ )
69
+ expect(eps.graphql)->toBe("https://merged.example/")
70
+ })
71
+
72
+ testSync("fails naming both keys when the stack exports neither API endpoint", () =>
73
+ switch ReventlessSeedAws.endpointsFrom(
74
+ ~stack="pr-verify",
75
+ StackOutputs(obj([("cognitoRegion", str("eu-west-1"))])),
76
+ ) {
77
+ | _ => fail("expected endpointsFrom to fail")
78
+ | exception Seed.Failed(msg) =>
79
+ expect(msg)->toContain("domainMergedApiEndpoint")
80
+ expect(msg)->toContain("domainApiEndpoint")
81
+ }
82
+ )
83
+ })
@@ -0,0 +1,115 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as JestGlobals from "@reventlessdev/rescript-jest/src/JestGlobals.res.mjs";
4
+ import * as ReventlessSeedAws from "../src/ReventlessSeedAws.res.mjs";
5
+ import * as Seed$ReventlessSeed from "@reventlessdev/reventless-seed/src/Seed.res.mjs";
6
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
7
+
8
+ function clearOverrides() {
9
+ [
10
+ "REVENTLESS_GRAPHQL_ENDPOINT",
11
+ "AWS_REGION",
12
+ "COGNITO_CLIENT_ID",
13
+ "SEED_SKIP_UPLOADS"
14
+ ].forEach(k => {
15
+ process.env[k] = "";
16
+ });
17
+ }
18
+
19
+ function obj(entries) {
20
+ return Object.fromEntries(entries);
21
+ }
22
+
23
+ function str(prim) {
24
+ return prim;
25
+ }
26
+
27
+ function config(extra) {
28
+ return Object.fromEntries([
29
+ [
30
+ "apiEndpoint",
31
+ "https://api.example/graphql"
32
+ ],
33
+ [
34
+ "region",
35
+ "eu-west-1"
36
+ ],
37
+ [
38
+ "cognitoClientId",
39
+ "client-abc"
40
+ ]
41
+ ].concat(extra));
42
+ }
43
+
44
+ function outputs(extra) {
45
+ return Object.fromEntries([
46
+ [
47
+ "domainApiEndpoint",
48
+ "https://domain.example/graphql"
49
+ ],
50
+ [
51
+ "cognitoRegion",
52
+ "eu-west-1"
53
+ ],
54
+ [
55
+ "cognitoUserPoolClientId",
56
+ "client-abc"
57
+ ]
58
+ ].concat(extra));
59
+ }
60
+
61
+ globalThis.describe("endpointsFrom — host shell arm", () => {
62
+ globalThis.beforeEach(clearOverrides);
63
+ globalThis.test("reads the GraphQL endpoint from the shell config", () => {
64
+ let eps = ReventlessSeedAws.endpointsFrom("alpha", {
65
+ TAG: "HostShellConfig",
66
+ _0: config([])
67
+ });
68
+ globalThis.expect(eps.graphql).toBe("https://api.example/graphql");
69
+ });
70
+ });
71
+
72
+ globalThis.describe("endpointsFrom — stack outputs arm", () => {
73
+ globalThis.beforeEach(clearOverrides);
74
+ globalThis.test("prefers the merged API endpoint over the per-plugin one", () => {
75
+ let eps = ReventlessSeedAws.endpointsFrom("pr-verify", {
76
+ TAG: "StackOutputs",
77
+ _0: outputs([[
78
+ "domainMergedApiEndpoint",
79
+ "https://merged.example/"
80
+ ]])
81
+ });
82
+ globalThis.expect(eps.graphql).toBe("https://merged.example/");
83
+ });
84
+ globalThis.test("fails naming both keys when the stack exports neither API endpoint", () => {
85
+ let val;
86
+ try {
87
+ val = ReventlessSeedAws.endpointsFrom("pr-verify", {
88
+ TAG: "StackOutputs",
89
+ _0: Object.fromEntries([[
90
+ "cognitoRegion",
91
+ "eu-west-1"
92
+ ]])
93
+ });
94
+ } catch (raw_msg) {
95
+ let msg = Primitive_exceptions.internalToException(raw_msg);
96
+ if (msg.RE_EXN_ID === Seed$ReventlessSeed.Failed) {
97
+ let msg$1 = msg._1;
98
+ globalThis.expect(msg$1).toContain("domainMergedApiEndpoint");
99
+ globalThis.expect(msg$1).toContain("domainApiEndpoint");
100
+ return;
101
+ }
102
+ throw msg;
103
+ }
104
+ JestGlobals.fail("expected endpointsFrom to fail");
105
+ });
106
+ });
107
+
108
+ export {
109
+ clearOverrides,
110
+ obj,
111
+ str,
112
+ config,
113
+ outputs,
114
+ }
115
+ /* Not a pure module */
@@ -0,0 +1,84 @@
1
+ // The half of reconciliation that decides whether an object is referenced.
2
+ //
3
+ // Everything else in the report is counting; `collectRefs` is the judgement, and
4
+ // it is judgement in one direction only. A ref it misses turns a live object
5
+ // into an "unreferenced, tagged pending" row — which is the row an operator
6
+ // reads as permission to enable an expiry rule. A ref it collects too eagerly
7
+ // only ever leaves an object alive.
8
+ //
9
+ // So these tests are about reach: nesting, arrays, records, and the prefix
10
+ // boundary that separates one store from another.
11
+
12
+ open JestGlobals
13
+
14
+ module Reconcile = ReventlessSeedAws_Reconcile
15
+
16
+ let prefix = "Catalog/productImages"
17
+ let refIn = "/Catalog/productImages/user-1/uuid/photo.png"
18
+ let refOther = "/Ordering/receipts/user-1/uuid/r.pdf"
19
+
20
+ let collect = (json: JSON.t): array<string> => {
21
+ let out = Set.make()
22
+ Reconcile.collectRefs(~prefix, json, out)
23
+ out->Set.toArray
24
+ }
25
+
26
+ let obj = (fields: array<(string, JSON.t)>) => fields->Dict.fromArray->JSON.Encode.object
27
+
28
+ describe("ReventlessSeedAws_Reconcile.collectRefs", () => {
29
+ testSync("finds a ref on a top-level field", () =>
30
+ expect(collect(obj([("imageUrl", JSON.Encode.string(refIn))])))->toEqual([refIn])
31
+ )
32
+
33
+ // Deliberately not declaration-driven: the claim component reads `@storageRef`
34
+ // annotations, so a report reading the same list would inherit the same blind
35
+ // spot and agree precisely where both are wrong. An unannotated field holding a
36
+ // ref has to show up here, as the thing to fix before expiry goes on.
37
+ testSync("finds a ref on a field no annotation would have named", () =>
38
+ expect(collect(obj([("someUndeclaredField", JSON.Encode.string(refIn))])))->toEqual([refIn])
39
+ )
40
+
41
+ testSync("finds refs inside an array", () =>
42
+ expect(collect(obj([("images", JSON.Encode.array([JSON.Encode.string(refIn)]))])))->toEqual([
43
+ refIn,
44
+ ])
45
+ )
46
+
47
+ testSync("finds a ref nested inside a record", () =>
48
+ expect(collect(obj([("variant", obj([("photo", JSON.Encode.string(refIn))]))])))->toEqual([refIn])
49
+ )
50
+
51
+ // Store isolation: one bucket holds several stores under distinct prefixes, so
52
+ // a reconciliation scoped to one store must not count another's refs — that
53
+ // would mark a genuinely abandoned object as referenced and hide it forever.
54
+ testSync("ignores a ref belonging to another store", () =>
55
+ expect(collect(obj([("receipt", JSON.Encode.string(refOther))])))->toEqual([])
56
+ )
57
+
58
+ // The prefix boundary is a path segment, not a string prefix.
59
+ testSync("ignores a store whose name merely starts the same way", () =>
60
+ expect(
61
+ collect(obj([("x", JSON.Encode.string("/Catalog/productImagesArchive/u/i/p.png"))])),
62
+ )->toEqual([])
63
+ )
64
+
65
+ testSync("ignores strings that are not refs at all", () =>
66
+ expect(
67
+ collect(
68
+ obj([
69
+ ("name", JSON.Encode.string("A product")),
70
+ ("external", JSON.Encode.string("https://example.com/Catalog/productImages/p.png")),
71
+ ("count", JSON.Encode.int(3)),
72
+ ]),
73
+ ),
74
+ )->toEqual([])
75
+ )
76
+
77
+ testSync("collects each distinct ref once", () => {
78
+ let json = obj([
79
+ ("a", JSON.Encode.string(refIn)),
80
+ ("b", JSON.Encode.string(refIn)),
81
+ ])
82
+ expect(collect(json)->Array.length)->toBe(1)
83
+ })
84
+ })
@@ -0,0 +1,102 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as ReventlessSeedAws_Reconcile from "../src/ReventlessSeedAws_Reconcile.res.mjs";
4
+
5
+ let prefix = "Catalog/productImages";
6
+
7
+ let refIn = "/Catalog/productImages/user-1/uuid/photo.png";
8
+
9
+ let refOther = "/Ordering/receipts/user-1/uuid/r.pdf";
10
+
11
+ function collect(json) {
12
+ let out = new Set();
13
+ ReventlessSeedAws_Reconcile.collectRefs(prefix, json, out);
14
+ return Array.from(out);
15
+ }
16
+
17
+ function obj(fields) {
18
+ return Object.fromEntries(fields);
19
+ }
20
+
21
+ globalThis.describe("ReventlessSeedAws_Reconcile.collectRefs", () => {
22
+ globalThis.test("finds a ref on a top-level field", () => {
23
+ globalThis.expect(collect(Object.fromEntries([[
24
+ "imageUrl",
25
+ refIn
26
+ ]]))).toEqual([refIn]);
27
+ });
28
+ globalThis.test("finds a ref on a field no annotation would have named", () => {
29
+ globalThis.expect(collect(Object.fromEntries([[
30
+ "someUndeclaredField",
31
+ refIn
32
+ ]]))).toEqual([refIn]);
33
+ });
34
+ globalThis.test("finds refs inside an array", () => {
35
+ globalThis.expect(collect(Object.fromEntries([[
36
+ "images",
37
+ [refIn]
38
+ ]]))).toEqual([refIn]);
39
+ });
40
+ globalThis.test("finds a ref nested inside a record", () => {
41
+ globalThis.expect(collect(Object.fromEntries([[
42
+ "variant",
43
+ Object.fromEntries([[
44
+ "photo",
45
+ refIn
46
+ ]])
47
+ ]]))).toEqual([refIn]);
48
+ });
49
+ globalThis.test("ignores a ref belonging to another store", () => {
50
+ globalThis.expect(collect(Object.fromEntries([[
51
+ "receipt",
52
+ refOther
53
+ ]]))).toEqual([]);
54
+ });
55
+ globalThis.test("ignores a store whose name merely starts the same way", () => {
56
+ globalThis.expect(collect(Object.fromEntries([[
57
+ "x",
58
+ "/Catalog/productImagesArchive/u/i/p.png"
59
+ ]]))).toEqual([]);
60
+ });
61
+ globalThis.test("ignores strings that are not refs at all", () => {
62
+ globalThis.expect(collect(Object.fromEntries([
63
+ [
64
+ "name",
65
+ "A product"
66
+ ],
67
+ [
68
+ "external",
69
+ "https://example.com/Catalog/productImages/p.png"
70
+ ],
71
+ [
72
+ "count",
73
+ 3
74
+ ]
75
+ ]))).toEqual([]);
76
+ });
77
+ globalThis.test("collects each distinct ref once", () => {
78
+ let json = Object.fromEntries([
79
+ [
80
+ "a",
81
+ refIn
82
+ ],
83
+ [
84
+ "b",
85
+ refIn
86
+ ]
87
+ ]);
88
+ globalThis.expect(collect(json).length).toBe(1);
89
+ });
90
+ });
91
+
92
+ let Reconcile;
93
+
94
+ export {
95
+ Reconcile,
96
+ prefix,
97
+ refIn,
98
+ refOther,
99
+ collect,
100
+ obj,
101
+ }
102
+ /* Not a pure module */
@@ -0,0 +1,170 @@
1
+ // How the reset learns which uploaded objects belong to which plugin, and what
2
+ // it refuses.
3
+ //
4
+ // A declared store's bucket is built by the PLATFORM deploy, so its tags say
5
+ // which project built it, not whose data is in it. Ownership therefore comes
6
+ // from the platform's `objectStores` stack output, and these pin the pure half
7
+ // of that: parsing the output, and refusing any store set a prefix-scoped wipe
8
+ // could not separate. The impure half is a `pulumi stack output` subprocess and
9
+ // the S3 calls, and the decisions under test are in neither.
10
+
11
+ open JestGlobals
12
+
13
+ module Reset = ReventlessSeedAws_Reset
14
+
15
+ let obj = entries => JSON.Encode.object(entries->Dict.fromArray)
16
+ let str = JSON.Encode.string
17
+
18
+ let storeEntry = (~bucket, ~prefix) =>
19
+ obj([("bucketName", str(bucket)), ("keyPrefix", str(prefix))])
20
+
21
+ let output = entries => Some(obj(entries))
22
+
23
+ let store = (~qualified, ~bucket, ~prefix): Reset.objectStore => {
24
+ let (plugin, name) = Reset.splitQualified(qualified)->Option.getOr(("", qualified))
25
+ {qualified, plugin, store: name, bucketName: bucket, keyPrefix: prefix}
26
+ }
27
+
28
+ describe("parseObjectStores", () => {
29
+ testSync("splits the qualified key into plugin and store", () =>
30
+ switch Reset.parseObjectStores(
31
+ output([("Catalog.productImages", storeEntry(~bucket="alpha-stores", ~prefix="productImages"))]),
32
+ ) {
33
+ | Ok([s]) =>
34
+ expect((s.plugin, s.store, s.bucketName, s.keyPrefix))->toEqual((
35
+ "Catalog",
36
+ "productImages",
37
+ "alpha-stores",
38
+ "productImages",
39
+ ))
40
+ | other => fail(`expected one parsed store, got ${other->JSON.stringifyAny->Option.getOr("?")}`)
41
+ }
42
+ )
43
+
44
+ // A store name may contain a dot; a registered plugin name may not — so the
45
+ // split is at the first one, matching how the key is composed.
46
+ testSync("splits at the first dot only", () =>
47
+ switch Reset.splitQualified("Catalog.product.images") {
48
+ | Some((plugin, name)) => expect((plugin, name))->toEqual(("Catalog", "product.images"))
49
+ | None => fail("expected a split")
50
+ }
51
+ )
52
+
53
+ // A platform that declares no stores is ordinary, not an error.
54
+ testSync("absent output yields no stores", () =>
55
+ switch Reset.parseObjectStores(None) {
56
+ | Ok(stores) => expect(stores->Array.length)->toBe(0)
57
+ | Error(message) => fail(message)
58
+ }
59
+ )
60
+
61
+ // A store the reset cannot read is a store it would silently leave behind, so
62
+ // a malformed entry fails the run rather than being skipped.
63
+ testSync("a malformed entry is an error, not a skip", () =>
64
+ switch Reset.parseObjectStores(output([("Catalog.productImages", obj([("keyPrefix", str("x"))]))])) {
65
+ | Ok(_) => fail("expected a malformed entry to be refused")
66
+ | Error(message) => expect(message->String.includes("Catalog.productImages"))->toBe(true)
67
+ }
68
+ )
69
+
70
+ testSync("an unqualified key is an error", () =>
71
+ switch Reset.parseObjectStores(
72
+ output([("productImages", storeEntry(~bucket="alpha-stores", ~prefix="productImages"))]),
73
+ ) {
74
+ | Ok(_) => fail("expected an unqualified key to be refused")
75
+ | Error(message) => expect(message->String.includes("productImages"))->toBe(true)
76
+ }
77
+ )
78
+ })
79
+
80
+ describe("validateStores", () => {
81
+ testSync("distinct prefixes in one bucket are fine", () =>
82
+ expect(
83
+ Reset.validateStores([
84
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
85
+ store(~qualified="Ordering.labels", ~bucket="alpha-stores", ~prefix="labels"),
86
+ ]),
87
+ )->toEqual(Ok())
88
+ )
89
+
90
+ // The cross-plugin collision: one prefix, two owners, nothing to tell their
91
+ // objects apart.
92
+ testSync("two plugins on one prefix are refused", () =>
93
+ switch Reset.validateStores([
94
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
95
+ store(~qualified="Ordering.productImages", ~bucket="alpha-stores", ~prefix="productImages"),
96
+ ]) {
97
+ | Ok() => fail("expected a collision to be refused")
98
+ | Error(message) =>
99
+ expect(
100
+ message->String.includes("Catalog.productImages") &&
101
+ message->String.includes("Ordering.productImages"),
102
+ )->toBe(true)
103
+ }
104
+ )
105
+
106
+ // The same shape one level up: `Catalog` encloses `Catalog/productImages`, so
107
+ // wiping the first would delete the second's objects.
108
+ testSync("an enclosing prefix is refused", () =>
109
+ switch Reset.validateStores([
110
+ store(~qualified="Legacy.catalog", ~bucket="alpha-stores", ~prefix="Catalog"),
111
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix="Catalog/productImages"),
112
+ ]) {
113
+ | Ok() => fail("expected an enclosing prefix to be refused")
114
+ | Error(message) => expect(message->String.includes("encloses"))->toBe(true)
115
+ }
116
+ )
117
+
118
+ // Different buckets cannot reach each other, so an equal prefix there is not a
119
+ // collision — which is what the per-store layout produces in production.
120
+ testSync("one prefix in two different buckets is fine", () =>
121
+ expect(
122
+ Reset.validateStores([
123
+ store(~qualified="Catalog.productImages", ~bucket="catalog-productImages", ~prefix="productImages"),
124
+ store(~qualified="Ordering.productImages", ~bucket="ordering-productImages", ~prefix="productImages"),
125
+ ]),
126
+ )->toEqual(Ok())
127
+ )
128
+
129
+ // A shared prefix that merely starts with another's characters is not nesting:
130
+ // the delete prefix carries a trailing slash, so `images` cannot reach
131
+ // `images-archive`.
132
+ testSync("a sibling prefix sharing a character run is fine", () =>
133
+ expect(
134
+ Reset.validateStores([
135
+ store(~qualified="Catalog.images", ~bucket="alpha-stores", ~prefix="images"),
136
+ store(~qualified="Catalog.imagesArchive", ~bucket="alpha-stores", ~prefix="images-archive"),
137
+ ]),
138
+ )->toEqual(Ok())
139
+ )
140
+
141
+ testSync("an empty or slash-bearing store name is refused", () => {
142
+ switch Reset.validateStores([
143
+ store(~qualified="Catalog.productImages", ~bucket="alpha-stores", ~prefix=""),
144
+ ]) {
145
+ | Ok() => fail("expected an empty prefix to be refused")
146
+ | Error(_) => ()
147
+ }
148
+ switch Reset.validateStores([
149
+ store(~qualified="Catalog.a/b", ~bucket="alpha-stores", ~prefix="a/b"),
150
+ ]) {
151
+ | Ok() => fail("expected a store name containing a slash to be refused")
152
+ | Error(_) => ()
153
+ }
154
+ })
155
+ })
156
+
157
+ describe("pluginOf", () => {
158
+ // The `objectStores` keys carry the REGISTERED plugin name; the menu carries
159
+ // the operator-facing label. Attribution uses the declared name so the reset
160
+ // never has to guess the relation between the two.
161
+ testSync("prefers the declared plugin name over the label", () =>
162
+ expect(
163
+ Reset.pluginOf({projectDir: "../catalog-aws", label: "catalog", group: Domain, plugin: "Catalog"}),
164
+ )->toBe("Catalog")
165
+ )
166
+
167
+ testSync("falls back to the label when no plugin name is declared", () =>
168
+ expect(Reset.pluginOf({projectDir: ".", label: "platform", group: Platform}))->toBe("platform")
169
+ )
170
+ })